回溯入门

17. 电话号码的字母组合

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

本题是一个非常经典的回溯问题,每个数字有对应的可选字符,我们从第 1 个数字开始枚举其可以选择的所有字符,然后递归到第 2 个数字继续枚举,直到枚举完所有数字保存答案。

String[] MAPPING = { "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" };
List<String> ans = new ArrayList<>();
StringBuilder sb = new StringBuilder();
char[] digits;

public List<String> letterCombinations(String digits) {
    int n = digits.length();
    if (n == 0) {
        return ans;
    }
    this.digits = digits.toCharArray();
    dfs(0);
    return ans;
}

private void dfs(int i) {
    // 枚举完所有数字,结束递归,保存答案
    if (i == digits.length) {
        ans.add(sb.toString());
        return;
    }
    // 当前数字
    int num = digits[i] - '0';
    // 枚举当前数字可能选择的字符
    for (char c : MAPPING[num].toCharArray()) {
        sb.append(c); // 选择字符 c
        dfs(i + 1); // 递归下一个位置
        sb.deleteCharAt(sb.length() - 1); // 恢复现场
    }
}

一、子集型回溯

有「选或不选」和「枚举选哪个」两种写法。

  • 「选或者不选」的结果都位于「叶节点」

  • 而「枚举选哪个」的结果位于「每个节点」

78 子集

给你一个整数数组 nums ,数组中的元素「互不相同」 。返回该数组所有可能的子集(幂集)。

解集「不能」包含重复的子集。你可以按「任意顺序」返回解集。

从输入的角度考虑每个 nums[i] 是选还是不选,由此组合出所有子集。

List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();

public List<List<Integer>> subsets(int[] nums) {
    dfs(nums, 0);
    return ans;
}

private void dfs(int[] nums, int i){
    if (i == nums.length){
        ans.add(new ArrayList<>(path));
        return;
    }
    // 选
    path.add(nums[i]);
    dfs(nums, i + 1);
    path.removeLast();
    // 不选
    dfs(nums, i + 1);
}

2597. 美丽子集的数目

可以通过回溯实现,在选的过程中判断是否可以选。

Map<Integer, Integer> cnt = new HashMap<>();
int ans = -1; // 排除空集
int k;

public int beautifulSubsets(int[] nums, int k) {
    this.k = k;
    dfs(nums, 0);
    return ans;
}

public void dfs(int[] nums, int i) {
    if (i == nums.length) {
        ans++;
        return;
    }
    // 不选
    dfs(nums, i + 1);
    // 判断是否可以选
    int x = nums[i];
    if (cnt.getOrDefault(x + k, 0) == 0 && cnt.getOrDefault(x - k, 0) == 0) {
        cnt.merge(nums[i], 1, Integer::sum); // 选
        dfs(nums, i + 1);
        cnt.merge(nums[i], -1, Integer::sum); // 恢复现场
    }
}

枚举选哪个的写法:

List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();

public List<List<Integer>> subsets(int[] nums) {
    dfs(nums, 0);
    return ans;
}

private void dfs(int[] nums, int i){
    ans.add(new ArrayList<>(path));
    // 枚举选择的数字
    for (int j = i; j < nums.length; j++){
        path.add(nums[j]);
        dfs(nums, j + 1);
        path.removeLast();
    }
}

39. 组合总和

给你一个「无重复元素」的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有「不同组合」,并以列表形式返回。你可以按「任意顺序」返回这些组合。

candidates 中的「同一个」数字可以「无限制重复被选取」。如果至少一个数字的被选数量不同,则两种组合是不同的。

对于给定的输入,保证和为 target 的不同组合数少于 150 个。

这个思路和完全背包类似。具体使用 dfs(i, target) 来回溯,设当前枚举到 candidates[i],剩余要选的元素之和为 target,按照选或不选讨论:

  • 选,递归到 dfs(i, target-candidates[i])i 不变,表示可以重复选。

  • 不选,递归到 dfs(i+1,target)

List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();

public List<List<Integer>> combinationSum(int[] candidates, int target) {
    Arrays.sort(candidates);
    dfs(candidates, 0, target);
    return ans;
}

public void dfs(int[] candidates, int i, int target){
    // 找到合法组合
    if (target == 0){
        ans.add(new ArrayList<>(path));
        return;
    }
    // 剪枝优化:如果当前元素大于 target,可以直接退出
    if (i == candidates.length || target < candidates[i]){
        return;
    }
    // 选
    path.add(candidates[i]);
    dfs(candidates, i, target - candidates[i]);
    path.removeLast();
    // 不选
    dfs(candidates, i + 1, target);
}

40. 组合总和 II

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次 。

注意:解集不能包含重复的组合。

本题思路和【组合总和】相同,不同点在于本题需要去重,只需要在「不选」的时候跳过所有后续等于当前元素的数。

List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();

public List<List<Integer>> combinationSum2(int[] candidates, int target) {
    Arrays.sort(candidates);
    dfs(candidates, 0, target);
    return ans;
}

public void dfs(int[] candidates, int i, int target){
    if (target == 0){
        ans.add(new ArrayList<>(path));
        return;
    }
    if (i == candidates.length || target < candidates[i]){
        return;
    }
    // 选
    path.add(candidates[i]);
    dfs(candidates, i + 1, target - candidates[i]);
    path.removeLast();  // 恢复现场
    // 不选,则跳过所有相同的元素,避免重复
    int j = i + 1;
    while (j < candidates.length && candidates[j] == candidates[i]){
        j++;
    }
    dfs(candidates, j, target);
}

二、划分型回溯

131. 分割回文串

三、组合型回溯

77. 组合

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

从 n 遍历到 1,判断当前数字选或不选即可。当已选的数字数量等于 k 时,保存答案,结束递归。

List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();

public List<List<Integer>> combine(int n, int k) {
    dfs(n, k);
    return ans;
}

public void dfs(int i, int k) {
    // 还需要选 d 个数字
    int d = k - path.size();
    // 选好了
    if (d == 0){
        ans.add(new ArrayList<>(path));
        return;
    }
    // 剪枝优化:剩余元素不足以选够 k 个数
    if (i < d) {
        return;
    }
    // 选
    path.add(i);
    dfs(i - 1, k);
    path.removeLast();
    // 不选
    dfs(i - 1, k);
}

216. 组合总和 III

找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:

  • 只使用数字1到9

  • 每个数字「最多使用一次」

返回「所有可能的有效组合的列表」 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

避免组合重复只需要从「选或不选」的角度思考就很容易解决。

定义 dfs(i,d,t) 表示从数字 1 \dots i 中选择 d 个数和为 t 的所有情况,考虑当前数字 i 选或不选:

  • 选,递归到 dfs(i-1, d-1, t-i)

  • 不选,递归到 dfs(i-1,d,t)

由于我们按照顺序对数字进行「选或不选」,数字 1~9 本身不存在重复,因此不会出现重复组合。

本题需要进行剪枝优化才能过:

  • t < 0 时,无论怎么选都不可以满足条件,结束递归

  • 当选择所有剩余最大的元素都不能满足 t 时,结束递归

  • 剩余数字不够选时,结束递归

递归边界:数字选够了 d=0,且 t=0

List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();

public List<List<Integer>> combinationSum3(int k, int n) {
    dfs(9, k, n);
    return ans;
}

public void dfs(int i, int k, int t) {
    int d = k - path.size(); // 还需要选 d 个数
    // 剪枝优化
    if (t < 0 || t > (i * 2 - d + 1) * d / 2) {
        return;
    }
    if (d == 0) {
        ans.add(new ArrayList<>(path));
        return;
    }
    // 剪枝:剩余元素不够选
    if (i < d){
        return;
    }
    // 选
    path.add(i);
    dfs(i - 1, k, t - i);
    path.removeLast();
    // 不选
    dfs(i - 1, k, t);
}

22. 括号生成

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且「有效的」括号组合。

这道题可以转换为从 2n 个位置中选 n 个位置放左括号,因此是组合型回溯。

int n;
char[] path;
List<String> ans = new ArrayList<>();

public List<String> generateParenthesis(int n) {
    this.n = n;
    path = new char[n * 2];
    dfs(0, 0);
    return ans;
}

// i:目前要填括号的位置
// leftCnt:左括号的个数
public void dfs(int i, int leftCnt){
    // 括号构造完毕
    if (i == path.length){
        ans.add(new String(path));
        return;
    }
    // 左括号的个数未达上限
    if (leftCnt < n){
        path[i] = '(';
        dfs(i + 1, leftCnt + 1);
    }
    // 右括号 < 左括号 时才能填右括号
    if (i - leftCnt < leftCnt){
        path[i] = ')';
        dfs(i + 1, leftCnt);
    }
}

四、排列型回溯

46. 全排列

给定一个不含重复数字的数组 nums ,返回其「所有可能的全排列 」。你可以「按任意顺序」返回答案。

排列与组合不同的是,不同的顺序也是不同的排列。所有排列的长度是相同的,我们需要考虑每个位置上该选择什么数,已经选择的数不能再选。

  • 使用数组 path 记录路径上的数字(已选的数),布尔数组 used 记录已经选择的数字。

  • 枚举第 i 个位置可以选择的数字,选择可以选择的数字;

  • 向后枚举第 i + 1 个位置可以选择的数字,当 i = n 时结束递归,保存答案

List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();

public List<List<Integer>> permute(int[] nums) {
    dfs(0, nums, new boolean[nums.length]);
    return ans;
}

public void dfs(int i, int[] nums, boolean[] used) {
    if (i == nums.length) {
        ans.add(new ArrayList<>(path));
        return;
    }
    // 枚举第 i 个位置可以选择的数字
    for (int j = 0; j < nums.length; j++) {
        if (used[j]) {
            continue;
        }
        // 选择 nums[j]
        path.add(nums[j]);
        used[j] = true;
        // 向后枚举第 i + 1 个位置
        dfs(i + 1, nums, used);
        // 恢复现场
        path.removeLast();
        used[j] = false;
    }
}

51. N 皇后

52. N 皇后 II

五、爆搜+剪枝

79. 单词搜索