假设你需要生成数组 [1, 2, 3] 的所有排列。最直观的想法是写三重循环,每一层选择一个不同的元素——但如果数组长度是 10 呢?你需要写 10 层嵌套循环。更糟糕的是,如果数组长度不固定呢?

回溯算法为这类问题提供了一个优雅的通用解法:用递归模拟多层循环,在每一步做出选择,探索所有可能,并在需要时撤销选择、回退状态。这种"试错-回退"的模式,让它成为解决排列、组合、子集、棋盘问题等组合优化问题的核心技术。

回溯算法的本质

回溯(Backtracking)是一种通过探索所有可能解来解决问题的算法技术。它的核心思想非常朴素:从一条路往前走,能进则进,不能进则退

1950年代,美国数学家 D. H. Lehmer 首次使用"backtrack"一词描述这种搜索策略。此后,这门技术在约束满足问题、组合优化问题中得到了广泛应用。1972年,Niklaus Wirth 在其著作《算法+数据结构=程序》中将回溯形式化为一种系统性的问题求解方法。

回溯算法可以被形式化地理解为在状态空间树上进行深度优先搜索(DFS)。树的每个节点代表问题求解过程中的一个状态,从根节点到叶子节点的路径代表一个完整的候选解。当发现某条路径无法得到有效解时,算法会"回溯"到上一个节点,尝试其他分支。

回溯算法状态空间树

图片来源: labuladong 的算法小抄

回溯算法与普通 DFS 的关键区别在于剪枝。DFS 会遍历整棵树,而回溯会在探索过程中主动放弃那些明显不可能得到有效解的分支,从而大幅减少搜索空间。

三个核心操作

回溯算法的每一次递归调用都包含三个核心操作:

1. 做选择:将当前候选元素加入路径,更新状态。

2. 递归探索:基于当前状态继续向下探索。

3. 撤销选择:将之前的选择从路径中移除,恢复状态,为下一次选择做准备。

这三个步骤构成了回溯算法的基本框架。用伪代码表示:

void backtrack(路径, 选择列表) {
    if (满足结束条件) {
        result.add(路径);
        return;
    }
    for (选择 : 选择列表) {
        做选择;
        backtrack(路径, 选择列表);
        撤销选择;
    }
}

这个框架之所以强大,是因为它将复杂的组合问题分解为简单的"选择-探索-撤销"循环。每一层递归只需要考虑当前状态下的所有可能选择,而不需要关心之前或之后的决策。

LeetCode 回溯算法题目全景

回溯算法在 LeetCode 上有大量经典题目,可以按照问题类型分为以下几类:

类型 题目编号 题目名称
排列 46 Permutations
排列 47 Permutations II
组合 77 Combinations
组合 39 Combination Sum
组合 40 Combination Sum II
组合 216 Combination Sum III
子集 78 Subsets
子集 90 Subsets II
棋盘 51 N-Queens
棋盘 52 N-Queens II
棋盘 37 Sudoku Solver
字符串 17 Letter Combinations of a Phone Number
字符串 22 Generate Parentheses
字符串 93 Restore IP Addresses
字符串 131 Palindrome Partitioning
搜索 79 Word Search
其他 494 Target Sum
其他 784 Letter Case Permutation

接下来,我们将逐一分析这些经典题目,并提供完整的 Java 实现。

排列问题:元素顺序有影响

排列问题的特点是:从 n 个元素中选出 n 个,顺序不同则结果不同。比如 [1, 2, 3] 的全排列包含 [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]

LeetCode 46. Permutations

题目描述:给定一个不含重复数字的数组,返回其所有可能的全排列。

解题思路:每一层递归决定当前位置放哪个元素。为了保证每个元素只使用一次,需要维护一个 used 数组记录哪些元素已被选择。

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        boolean[] used = new boolean[nums.length];
        backtrack(nums, new ArrayList<>(), used, result);
        return result;
    }
    
    private void backtrack(int[] nums, List<Integer> path, 
                           boolean[] used, List<List<Integer>> result) {
        if (path.size() == nums.length) {
            result.add(new ArrayList<>(path));
            return;
        }
        
        for (int i = 0; i < nums.length; i++) {
            if (used[i]) continue;  // 剪枝:跳过已使用的元素
            
            used[i] = true;
            path.add(nums[i]);
            backtrack(nums, path, used, result);
            path.remove(path.size() - 1);
            used[i] = false;
        }
    }
}

复杂度分析

  • 时间复杂度:$O(n \times n!)$,共有 $n!$ 种排列,每种排列需要 $O(n)$ 时间复制到结果中。
  • 空间复杂度:$O(n)$,递归深度最大为 $n$。

LeetCode 47. Permutations II

题目描述:给定一个可包含重复数字的数组,返回所有不重复的全排列。

解题思路:与上一题的区别在于数组中有重复元素,需要额外的剪枝来避免生成重复排列。关键技巧是先排序,然后在同一层递归中跳过与上一个元素相同的候选。

class Solution {
    public List<List<Integer>> permuteUnique(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        Arrays.sort(nums);  // 排序是去重的前提
        boolean[] used = new boolean[nums.length];
        backtrack(nums, new ArrayList<>(), used, result);
        return result;
    }
    
    private void backtrack(int[] nums, List<Integer> path, 
                           boolean[] used, List<List<Integer>> result) {
        if (path.size() == nums.length) {
            result.add(new ArrayList<>(path));
            return;
        }
        
        for (int i = 0; i < nums.length; i++) {
            if (used[i]) continue;
            // 剪枝:如果当前元素与前一个相同,且前一个未被使用(说明是同一层)
            // 则跳过,避免重复排列
            if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;
            
            used[i] = true;
            path.add(nums[i]);
            backtrack(nums, path, used, result);
            path.remove(path.size() - 1);
            used[i] = false;
        }
    }
}

去重逻辑解析!used[i - 1] 的含义是:前一个相同元素在本层递归中刚刚被撤销选择,说明它是"同一层"的前一个选择。如果此时再选择当前元素,就会产生与之前相同的排列。

组合问题:元素顺序无关

组合问题与排列问题的核心区别在于:组合只关心选了哪些元素,不关心顺序。比如 [1, 2][2, 1] 是同一个组合。

LeetCode 77. Combinations

题目描述:给定整数 n 和 k,返回 1 到 n 中所有可能的 k 个数的组合。

解题思路:为了避免重复,我们需要保证每次选择时只考虑比上一个选择更大的元素。这就是通过 startIndex 参数实现的"向前看"策略。

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> result = new ArrayList<>();
        backtrack(n, k, 1, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(int n, int k, int start, 
                           List<Integer> path, List<List<Integer>> result) {
        if (path.size() == k) {
            result.add(new ArrayList<>(path));
            return;
        }
        
        // 剪枝优化:如果剩余可选元素不足,提前终止
        for (int i = start; i <= n - (k - path.size()) + 1; i++) {
            path.add(i);
            backtrack(n, k, i + 1, path, result);
            path.remove(path.size() - 1);
        }
    }
}

剪枝优化:循环上界从 n 改为 n - (k - path.size()) + 1。原理是:如果还需要选 k - path.size() 个元素,但剩余可选元素只有 n - i + 1 个,当后者小于前者时,可以直接跳过。

LeetCode 39. Combination Sum

题目描述:给定无重复元素的数组 candidates 和目标值 target,找出所有和为 target 的组合。同一数字可以重复选取。

解题思路:与上一题类似,但由于可以重复选取,递归时的起始索引不再是 i + 1,而是 i(允许再次选择当前元素)。

class Solution {
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<List<Integer>> result = new ArrayList<>();
        backtrack(candidates, target, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(int[] candidates, int target, int start,
                           List<Integer> path, List<List<Integer>> result) {
        if (target == 0) {
            result.add(new ArrayList<>(path));
            return;
        }
        if (target < 0) return;  // 剪枝:和已超过目标
        
        for (int i = start; i < candidates.length; i++) {
            path.add(candidates[i]);
            // 注意:这里传 i 而不是 i + 1,因为可以重复选取
            backtrack(candidates, target - candidates[i], i, path, result);
            path.remove(path.size() - 1);
        }
    }
}

LeetCode 40. Combination Sum II

题目描述:给定可能包含重复元素的数组 candidates 和目标值 target,找出所有和为 target 的组合。每个数字只能使用一次。

解题思路:结合了"去重"和"组合"两个要点。先排序,然后在同一层递归中跳过重复元素。

class Solution {
    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        List<List<Integer>> result = new ArrayList<>();
        Arrays.sort(candidates);
        backtrack(candidates, target, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(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 (i > start && candidates[i] == candidates[i - 1]) continue;
            // 剪枝:如果当前元素已超过剩余目标,后面的更大元素更不可能
            if (candidates[i] > target) break;
            
            path.add(candidates[i]);
            backtrack(candidates, target - candidates[i], i + 1, path, result);
            path.remove(path.size() - 1);
        }
    }
}

LeetCode 216. Combination Sum III

题目描述:找出所有相加之和为 n 的 k 个数的组合,数字范围 1-9,每个数字最多使用一次。

解题思路:这是组合问题的简化版本,候选集固定为 1-9。

class Solution {
    public List<List<Integer>> combinationSum3(int k, int n) {
        List<List<Integer>> result = new ArrayList<>();
        backtrack(k, n, 1, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(int k, int n, int start, 
                           List<Integer> path, List<List<Integer>> result) {
        if (path.size() == k && n == 0) {
            result.add(new ArrayList<>(path));
            return;
        }
        if (path.size() >= k || n <= 0) return;
        
        for (int i = start; i <= 9; i++) {
            path.add(i);
            backtrack(k, n - i, i + 1, path, result);
            path.remove(path.size() - 1);
        }
    }
}

子集问题:枚举所有可能

子集问题要求生成数组的所有子集,包括空集。从决策树的角度看,子集问题与组合问题的区别在于:子集问题在每个节点都要收集结果,而组合问题只在叶子节点收集。

LeetCode 78. Subsets

题目描述:给定不含重复元素的整数数组,返回所有可能的子集。

解题思路:每一层递归决定是否选择当前元素。注意在每个节点(包括根节点)都要将当前路径加入结果。

class Solution {
    public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        backtrack(nums, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(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]);
            backtrack(nums, i + 1, path, result);
            path.remove(path.size() - 1);
        }
    }
}

时间复杂度:$O(n \times 2^n)$。共有 $2^n$ 个子集,每个子集需要 $O(n)$ 时间复制。

LeetCode 90. Subsets II

题目描述:给定可能包含重复元素的整数数组,返回所有可能的子集(不能有重复子集)。

解题思路:排序 + 同层去重。

class Solution {
    public List<List<Integer>> subsetsWithDup(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        Arrays.sort(nums);
        backtrack(nums, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(int[] nums, int start, 
                           List<Integer> path, List<List<Integer>> result) {
        result.add(new ArrayList<>(path));
        
        for (int i = start; i < nums.length; i++) {
            if (i > start && nums[i] == nums[i - 1]) continue;  // 去重
            
            path.add(nums[i]);
            backtrack(nums, i + 1, path, result);
            path.remove(path.size() - 1);
        }
    }
}

棋盘问题:约束满足的典型

棋盘问题是回溯算法的经典应用场景,特点是需要在放置元素时检查多个约束条件。

LeetCode 51. N-Queens

题目描述:在 n×n 的棋盘上放置 n 个皇后,使其不能互相攻击。返回所有可能的放置方案。

解题思路:逐行放置皇后,每次放置时检查列、主对角线、副对角线是否已有皇后。

class Solution {
    public List<List<String>> solveNQueens(int n) {
        List<List<String>> result = new ArrayList<>();
        char[][] board = new char[n][n];
        for (int i = 0; i < n; i++) {
            Arrays.fill(board[i], '.');
        }
        backtrack(board, 0, result);
        return result;
    }
    
    private void backtrack(char[][] board, int row, List<List<String>> result) {
        if (row == board.length) {
            result.add(constructBoard(board));
            return;
        }
        
        for (int col = 0; col < board.length; col++) {
            if (!isValid(board, row, col)) continue;  // 剪枝
            
            board[row][col] = 'Q';
            backtrack(board, row + 1, result);
            board[row][col] = '.';  // 撤销选择
        }
    }
    
    private boolean isValid(char[][] board, int row, int col) {
        // 检查列
        for (int i = 0; i < row; i++) {
            if (board[i][col] == 'Q') return false;
        }
        // 检查左上对角线
        for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
            if (board[i][j] == 'Q') return false;
        }
        // 检查右上对角线
        for (int i = row - 1, j = col + 1; i >= 0 && j < board.length; i--, j++) {
            if (board[i][j] == 'Q') return false;
        }
        return true;
    }
    
    private List<String> constructBoard(char[][] board) {
        List<String> result = new ArrayList<>();
        for (char[] row : board) {
            result.add(new String(row));
        }
        return result;
    }
}

优化技巧:可以用三个布尔数组分别记录已占用的列、主对角线、副对角线,将 isValid 检查降到 $O(1)$。

class Solution {
    public List<List<String>> solveNQueens(int n) {
        List<List<String>> result = new ArrayList<>();
        boolean[] cols = new boolean[n];
        boolean[] diag1 = new boolean[2 * n - 1];  // row - col + n - 1
        boolean[] diag2 = new boolean[2 * n - 1];  // row + col
        
        char[][] board = new char[n][n];
        for (int i = 0; i < n; i++) {
            Arrays.fill(board[i], '.');
        }
        
        backtrack(board, 0, cols, diag1, diag2, result);
        return result;
    }
    
    private void backtrack(char[][] board, int row, boolean[] cols, 
                           boolean[] diag1, boolean[] diag2, 
                           List<List<String>> result) {
        if (row == board.length) {
            result.add(constructBoard(board));
            return;
        }
        
        for (int col = 0; col < board.length; col++) {
            int d1 = row - col + board.length - 1;
            int d2 = row + col;
            
            if (cols[col] || diag1[d1] || diag2[d2]) continue;
            
            cols[col] = true;
            diag1[d1] = true;
            diag2[d2] = true;
            board[row][col] = 'Q';
            
            backtrack(board, row + 1, cols, diag1, diag2, result);
            
            board[row][col] = '.';
            cols[col] = false;
            diag1[d1] = false;
            diag2[d2] = false;
        }
    }
    
    private List<String> constructBoard(char[][] board) {
        List<String> result = new ArrayList<>();
        for (char[] row : board) {
            result.add(new String(row));
        }
        return result;
    }
}

LeetCode 37. Sudoku Solver

题目描述:解数独谜题。

解题思路:遍历每个空格,尝试填入 1-9,检查是否满足数独规则(行、列、3×3 宫格内无重复)。

class Solution {
    public void solveSudoku(char[][] board) {
        backtrack(board);
    }
    
    private boolean backtrack(char[][] board) {
        for (int row = 0; row < 9; row++) {
            for (int col = 0; col < 9; col++) {
                if (board[row][col] != '.') continue;
                
                for (char c = '1'; c <= '9'; c++) {
                    if (!isValid(board, row, col, c)) continue;
                    
                    board[row][col] = c;
                    if (backtrack(board)) return true;  // 找到解立即返回
                    board[row][col] = '.';  // 回溯
                }
                return false;  // 1-9 都不行,无解
            }
        }
        return true;  // 所有格子都填满了
    }
    
    private boolean isValid(char[][] board, int row, int col, char c) {
        for (int i = 0; i < 9; i++) {
            if (board[row][i] == c) return false;  // 检查行
            if (board[i][col] == c) return false;  // 检查列
            // 检查 3x3 宫格
            if (board[3 * (row / 3) + i / 3][3 * (col / 3) + i % 3] == c) {
                return false;
            }
        }
        return true;
    }
}

字符串问题:分割与匹配

字符串相关的回溯问题通常涉及字符串的分割、匹配或生成。

LeetCode 17. Letter Combinations of a Phone Number

题目描述:给定数字字符串,返回所有可能的字母组合(电话号码对应字母)。

解题思路:每个数字对应多个字母,问题转化为在每一层选择一个字母,组合成所有可能的结果。

class Solution {
    private String[] mapping = {"", "", "abc", "def", "ghi", "jkl", 
                                "mno", "pqrs", "tuv", "wxyz"};
    
    public List<String> letterCombinations(String digits) {
        List<String> result = new ArrayList<>();
        if (digits == null || digits.length() == 0) return result;
        backtrack(digits, 0, new StringBuilder(), result);
        return result;
    }
    
    private void backtrack(String digits, int index, 
                           StringBuilder path, List<String> result) {
        if (index == digits.length()) {
            result.add(path.toString());
            return;
        }
        
        String letters = mapping[digits.charAt(index) - '0'];
        for (char c : letters.toCharArray()) {
            path.append(c);
            backtrack(digits, index + 1, path, result);
            path.deleteCharAt(path.length() - 1);
        }
    }
}

LeetCode 22. Generate Parentheses

题目描述:生成所有有效的括号组合。

解题思路:在任意位置,左括号数量必须大于等于右括号数量。利用这个约束进行剪枝。

class Solution {
    public List<String> generateParenthesis(int n) {
        List<String> result = new ArrayList<>();
        backtrack(new StringBuilder(), 0, 0, n, result);
        return result;
    }
    
    private void backtrack(StringBuilder path, int open, int close, 
                           int n, List<String> result) {
        if (path.length() == 2 * n) {
            result.add(path.toString());
            return;
        }
        
        if (open < n) {  // 还可以放左括号
            path.append('(');
            backtrack(path, open + 1, close, n, result);
            path.deleteCharAt(path.length() - 1);
        }
        if (close < open) {  // 右括号数量必须小于左括号
            path.append(')');
            backtrack(path, open, close + 1, n, result);
            path.deleteCharAt(path.length() - 1);
        }
    }
}

LeetCode 93. Restore IP Addresses

题目描述:从数字字符串恢复所有可能的 IP 地址。

解题思路:IP 地址分为 4 段,每段 1-3 位数字,值在 0-255 之间。不能有前导零(除非是单独的 0)。

class Solution {
    public List<String> restoreIpAddresses(String s) {
        List<String> result = new ArrayList<>();
        backtrack(s, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(String s, int start, List<String> path, 
                           List<String> result) {
        if (path.size() == 4) {
            if (start == s.length()) {
                result.add(String.join(".", path));
            }
            return;
        }
        
        for (int len = 1; len <= 3 && start + len <= s.length(); len++) {
            String segment = s.substring(start, start + len);
            // 剪枝:检查有效性
            if (segment.length() > 1 && segment.charAt(0) == '0') continue;
            if (Integer.parseInt(segment) > 255) continue;
            
            path.add(segment);
            backtrack(s, start + len, path, result);
            path.remove(path.size() - 1);
        }
    }
}

LeetCode 131. Palindrome Partitioning

题目描述:将字符串分割成若干回文子串,返回所有可能的分割方案。

解题思路:在每一步尝试所有可能的分割点,检查分割出的子串是否是回文。

class Solution {
    public List<List<String>> partition(String s) {
        List<List<String>> result = new ArrayList<>();
        backtrack(s, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(String s, int start, List<String> path, 
                           List<List<String>> result) {
        if (start == s.length()) {
            result.add(new ArrayList<>(path));
            return;
        }
        
        for (int end = start; end < s.length(); end++) {
            if (!isPalindrome(s, start, end)) continue;  // 剪枝
            
            path.add(s.substring(start, end + 1));
            backtrack(s, end + 1, path, result);
            path.remove(path.size() - 1);
        }
    }
    
    private boolean isPalindrome(String s, int left, int right) {
        while (left < right) {
            if (s.charAt(left++) != s.charAt(right--)) {
                return false;
            }
        }
        return true;
    }
}

题目描述:在二维网格中搜索单词是否存在。

解题思路:从每个位置开始 DFS,向四个方向探索。需要标记已访问位置以避免重复使用。

class Solution {
    public boolean exist(char[][] board, String word) {
        for (int i = 0; i < board.length; i++) {
            for (int j = 0; j < board[0].length; j++) {
                if (backtrack(board, word, i, j, 0)) {
                    return true;
                }
            }
        }
        return false;
    }
    
    private boolean backtrack(char[][] board, String word, 
                              int row, int col, int index) {
        if (index == word.length()) return true;
        if (row < 0 || row >= board.length || 
            col < 0 || col >= board[0].length ||
            board[row][col] != word.charAt(index)) {
            return false;
        }
        
        char temp = board[row][col];
        board[row][col] = '#';  // 标记已访问
        
        boolean found = backtrack(board, word, row + 1, col, index + 1) ||
                        backtrack(board, word, row - 1, col, index + 1) ||
                        backtrack(board, word, row, col + 1, index + 1) ||
                        backtrack(board, word, row, col - 1, index + 1);
        
        board[row][col] = temp;  // 恢复
        return found;
    }
}

其他经典问题

LeetCode 494. Target Sum

题目描述:在数组每个元素前添加 + 或 -,计算所有可能的表达式值等于 target 的方案数。

解题思路:每个元素有两种选择:加或减。这可以转化为子集和问题。

class Solution {
    private int count = 0;
    
    public int findTargetSumWays(int[] nums, int target) {
        backtrack(nums, target, 0, 0);
        return count;
    }
    
    private void backtrack(int[] nums, int target, int index, int sum) {
        if (index == nums.length) {
            if (sum == target) count++;
            return;
        }
        
        backtrack(nums, target, index + 1, sum + nums[index]);  // 选择 +
        backtrack(nums, target, index + 1, sum - nums[index]);  // 选择 -
    }
}

优化:可以转化为 0-1 背包问题,使用动态规划将时间复杂度从 $O(2^n)$ 降到 $O(n \times sum)$。

LeetCode 784. Letter Case Permutation

题目描述:将字符串中的字母转换为大小写,返回所有可能的组合。

解题思路:对于每个字母,可以选择保持原样或转换大小写。

class Solution {
    public List<String> letterCasePermutation(String s) {
        List<String> result = new ArrayList<>();
        backtrack(s.toCharArray(), 0, result);
        return result;
    }
    
    private void backtrack(char[] chars, int index, List<String> result) {
        if (index == chars.length) {
            result.add(new String(chars));
            return;
        }
        
        if (Character.isLetter(chars[index])) {
            chars[index] = Character.toLowerCase(chars[index]);
            backtrack(chars, index + 1, result);
            chars[index] = Character.toUpperCase(chars[index]);
            backtrack(chars, index + 1, result);
        } else {
            backtrack(chars, index + 1, result);
        }
    }
}

剪枝技术的艺术

回溯算法的效率很大程度上取决于剪枝的质量。好的剪枝可以将指数级的时间复杂度大幅降低。以下是几种常见的剪枝策略:

1. 约束剪枝

在状态无效时立即返回,避免无效搜索。比如在组合求和问题中,当当前和已经超过目标值时停止。

if (currentSum > target) return;  // 约束剪枝

2. 边界剪枝

当剩余选择不足以完成目标时提前终止。比如在组合问题中,如果剩余元素数量不足以凑齐 k 个,直接返回。

// 还需要选 need 个元素,剩余可选元素有 remaining 个
if (remaining < need) return;  // 边界剪枝

3. 重复剪枝

在数组有重复元素时,通过排序 + 跳过同层重复元素避免生成重复解。

if (i > start && nums[i] == nums[i - 1]) continue;  // 重复剪枝

4. 可行性剪枝

在选择之前预判该选择是否可能成功。比如在 N 皇后问题中,放置皇后前检查是否冲突。

if (!isValid(board, row, col)) continue;  // 可行性剪枝

复杂度分析与优化

回溯算法的时间复杂度通常是指数级的,但在实际应用中,有效的剪枝可以将搜索空间大幅压缩。

问题类型 时间复杂度 空间复杂度
排列 $O(n \times n!)$ $O(n)$
组合 $O(k \times C_n^k)$ $O(k)$
子集 $O(n \times 2^n)$ $O(n)$
N皇后 $O(n!)$ $O(n)$

常见优化技巧

1. 预处理:排序数组,便于去重和提前终止。

2. 状态压缩:使用位运算替代布尔数组,减少空间占用。

3. 记忆化:对于存在重叠子问题的情况,可以用 HashMap 缓存中间结果。

4. 迭代加深:当只需要一个解时,可以用迭代加深搜索(IDS)优化。

总结

回溯算法的本质是在状态空间树上进行深度优先搜索,通过"选择-探索-撤销"的模式穷举所有可能解。它的强大之处在于框架的通用性——无论是排列、组合、子集还是棋盘问题,都可以用同一套模板解决。

掌握回溯算法的关键是理解三件事:

  1. 状态表示:如何用变量表示当前搜索状态?
  2. 选择集合:在当前状态下有哪些可选操作?
  3. 剪枝条件:如何提前判断某条路径不可能得到有效解?

当这三点想清楚后,套用回溯模板就是水到渠成的事。在实际刷题中,建议从简单的子集、组合问题入手,逐步挑战 N 皇后、数独等复杂问题,体会剪枝对效率的影响。


参考资料

  1. Wikipedia. Backtracking
  2. GeeksforGeeks. Backtracking Algorithms
  3. LeetCode Discuss. Backtracking algorithm + problems to practice
  4. LeetCode Discuss. Famous Backtracking Problems
  5. labuladong. Backtracking Algorithm to Solve All Permutation/Combination/Subset Problems
  6. Medium. A tree-based introduction to backtracking