代码随想录之回溯算法(一)

122 阅读9分钟

回溯算法

T77-组合

LeetCode第77题组合

题目描述

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

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

我的思路

  • 回溯算法就是一棵棵决策树的生成过程,最重要的就是根节点与子节点的确定
    • 谁可以做根节点:1~n
    • 谁可以做子节点:i + 1 ~ n
  • 回溯的过程,需要明确以下几点:
    • 明确回溯方法的返回条件是什么?
    • 一定要先插入值,再判断返回条件,有效避免重复的问题
    • 临时列表,插入元素之后,最后要清除元素
private List<List<Integer>> resList = new ArrayList<>();


    /**
     * 组合
     * @param n
     * @param k
     * @return
     */
    public List<List<Integer>> combine(int n, int k) {

        List<Integer> temp = new ArrayList<>();

        // 根节点元素可能为
        for (int i = 1; i <= n; i++) {
            backtrace(i, n, k, temp);
        }

        return resList;

    }

    /**
     * 回溯算法构建结果集
     * @param i
     * @param k
     * @param temp
     */
    private void backtrace(int i, int n, int k, List<Integer> temp) {

        // 先加入当前节点
        temp.add(i);

        // 判断返回条件
        if (temp.size() == k) {
            resList.add(new ArrayList<>(temp));
            // 移除元素
            temp.remove(temp.size() - 1);
            return;
        }


        // 遍历孩子节点
        for (int j = i + 1; j <= n; j++) {
            backtrace(j, n, k, temp);
        }

        // 将当前节点删除
        temp.remove(temp.size() - 1);

    }
    

时间复杂度:O(N!)O(N!)

T216-组合总和III

题目描述

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

  • 只使用数字1到9
  • 每个数字 最多使用一次 

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

我的思路

  • 实例变量curSum记录当前的总和
  • 回溯算法返回条件:curSum == n && temp.size() == k
  • 每个数字只能用1次,可以剪枝

实现代码

private List<List<Integer>> resList = new ArrayList<>();
    private int curSum = 0;
    /**
     * 找出所有相加之和为 n 的 k 个数的组合, 每个数字只能用一次
     * @param k
     * @param n
     * @return
     */
    public List<List<Integer>> combinationSum3(int k, int n) {

        int maxBound = Math.min(9, n);

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

        for (int i = 1; i <= maxBound; i++) {
            backtrace(i, k, n, path);
        }

        return resList;
    }

    private void backtrace(int i, int k, int n, List<Integer> path) {

        if (curSum > n || curSum + i > n) {
            return;
        }

        // 将当前的节点加入路径
        path.add(i);
        curSum += i;

        // 判断是否应该返回
        if (curSum == n && path.size() == k) {
            resList.add(new ArrayList<>(path));
            // 移除当前节点
            path.remove(path.size() -1);
            curSum -= i;
            return;
        }

        for (int j = i + 1; j <= 9; j++) {
            backtrace(j, k, n, path);
        }

        // 将当前节点移除路径
        path.remove(path.size() - 1);
        curSum -= i;
    }

T17-电话号码的字母组合

见LeetCode第17题电话号码的字母组合

题目描述

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

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

我的思路

  • 典型的回溯算法
  • 每个节点,他能做的决策性就是该数字对应的字母
  • 考虑使用Map存储数字到字母的映射
/**
     *
     * @param start digit 的下表
     * @param i 映射的第 i 个字母
     * @param digits
     * @param sb
     */
    private void backtrace(int start, int i,  String digits, StringBuilder sb) {
        if (start >= digits.length()) return;
        // 添加当前字符
        sb.append(codebook.get(digits.charAt(start)).get(i));

        // 如果当前字符是最后一个字符
        if (start == digits.length() - 1) {
            resList.add(new String(sb));
            sb.deleteCharAt(sb.length() - 1);
            return;
        }

        for (int j = 0; j < codebook.get(digits.charAt(start + 1)).size(); j++) {
            backtrace(start + 1, j, digits, sb);
        }

        sb.deleteCharAt(sb.length() - 1);
    }

T39-组合总和

见LeetCode第39题组合总和

题目描述

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

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

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

示例 1:

输入: candidates = [2,3,6,7], target = 7
输出: [[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。

我的思路

  • 排序,首先将数组进行排序
  • 索引curIndex记录当前遍历的节点,遍历范围为[curIndex, length - 1]
  • 如果当前和值curSum > target,进行剪枝操作
  • 如果当前和值curSum == target,将路径放入到结果集中
  • 如何去重复呢?
    • 剪枝操作,如果两个兄弟节点相同,就可以剪枝了
    • 在遍历孩子节点的时候,遍历之前进行剪枝
    • 剪枝条件为:preIndex >= startIndex && candidate[preIndex] == candidate[startIndex],直接continue
    • 孩子节点回溯之后,需要更新前一个索引,将preIndex = startIndex

实现代码

private final List<List<Integer>> resList = new ArrayList<>();

    private int  curSum = 0;

    private int preIndex = -1;


    /**
     * 组合总和
     * @param candidates
     * @param target
     * @return
     */
    public List<List<Integer>> combinationSum(int[] candidates, int target) {

        if (candidates == null || candidates.length == 0) return resList;

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

        for (int i = 0; i < candidates.length; i++) {
            if (preIndex >= i && candidates[preIndex] == candidates[i]) {
                continue;
            }
            backtrace(i, candidates, target, path);
            preIndex = i;
        }

        return resList;



    }

    /**
     * 回溯求解
     * @param i 当前索引
     * @param candidates 目标数组
     * @param target 目标值
     * @param path 路径
     */
    private void backtrace(int i, int[] candidates, int target, List<Integer> path) {

        if (i >= candidates.length || curSum + candidates[i] > target) return;

        // 将当前数字添加到路径中
        path.add(candidates[i]);
        curSum += candidates[i];

        // 判读是否达到条件
        if (curSum == target) {
            resList.add(new ArrayList<>(path));
            // 将当前节点移除
            path.remove(path.size() - 1);
            curSum -= candidates[i];
            return;
        }

        // 遍历当子节点,加入路径
        for (int j = i; j < candidates.length; j++) {
            // 剪枝操作
            if (preIndex >= j && candidates[preIndex] == candidates[j]) {
                continue;
            }
            backtrace(j, candidates, target, path);
            preIndex = j;
        }

        // 将当前的节点移除
        path.remove(path.size() - 1);
        curSum -= candidates[i];

    }
    

T40-组合总和II

见Leetcode第40题组合总和II

题目描述

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

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

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

我的思路

  • 相较于第39题,这一题元素是只能用1次的。遍历决策树的时候,子节点一定会要明确
  • 因为元素不能重复,因此同样应该,先排序,再剪枝
  • 如何记录同级的前一个节点?preIndex应该使用局部变量还是全局变量?
    • 若想正确对同一层相同元素的剪枝,需要使用局部变量来记录前一个索引

我的题解

private List<List<Integer>> resList = new ArrayList<>();


    private int curSum = 0;

    private int preIndex = -1;


    /**
     * 组合总和
     * @param candidates
     * @param target
     * @return
     */
    public List<List<Integer>> combinationSum2(int[] candidates, int target) {

        if (candidates == null || candidates.length == 0) return resList;

        Arrays.sort(candidates);
        List<Integer> path = new ArrayList<>();


        // 根节点决策
        for (int i = 0; i < candidates.length; i++) {
            // 剪枝去重
            if (preIndex >= 0 && candidates[i] == candidates[preIndex]) continue;

            backtrace(i, candidates, target, path, preIndex);

            preIndex = i;

        }

        return resList;

    }

    private void backtrace(int i, int[] candidates, int target, List<Integer> path, int preIndex) {

        if (i >= candidates.length || curSum + candidates[i] > target) return;

        // 将当前元素加入到路径中
        path.add(candidates[i]);
        curSum += candidates[i];

        // 判断是否满足条件
        if (target == curSum) {
            resList.add(new ArrayList<>(path));
            // 将当前元素从路径中移除
            path.remove(path.size() - 1);
            curSum -= candidates[i];
            return;
        }

        // 递归遍历子节点
        for (int j = i + 1; j < candidates.length; j++) {
            // 剪枝
            if (preIndex >= i + 1 && candidates[j] == candidates[preIndex]) continue;

            backtrace(j, candidates, target, path, preIndex);
            preIndex = j;

        }

        // 将当前节点删除
        path.remove(path.size() - 1);
        curSum -= candidates[i];
    }

T131-分割回文串

见LeetCode第131题分割回文串

题目描述

给你一个字符串 s,请你将 **s **分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

示例 1:

输入: s = "aab"
输出: [["a","a","b"],["aa","b"]]

我的思路

  • 根据题意,这些子串是可以完全组合成字符串s的,也就是说,不能丢弃任何的字符
  • 先找根节点的人选,那些可以做根节点呢?
    • 理论上讲,根节点是以0索引所在字符为前缀的所有的子串
    • 通过判断是否为回文串进行剪枝
  • 回文串的判断,暂时使用startcurIndex进行遍历比较来判断,暴力解法

我的题解

private List<List<String>> resList = new ArrayList<>();
    /**
     * 分割回文串
     * @param s
     * @return
     */
    public List<List<String>> partition(String s) {
        
        List<String> temp = new ArrayList<>();
        if (s == null || s.length() <= 1) {
            temp.add(s);
            resList.add(temp);
            return resList;
        }
        int start = 0;
        // 遍历根节点,根节点为[start, i)之间的字符串
        for (int i = 1; i <= s.length(); i++) {
            // 剪枝逻辑
            if (isPalindrome(s, start, i)) {
                // 回溯寻找解
                backtrace(start, i, s, temp);
            }
        }
        return resList;
    }

    /**
     * 回溯寻找解
     * @param start
     * @param i
     * @param s
     * @param temp
     */
    private void backtrace(int start, int i, String s, List<String> temp) {
        // 首先将子串添加进来
        temp.add(s.substring(start, i));
        // 递归结束的条件为:i == s.length()
        if (i == s.length()) {
            // 临时结果添加到结果中
            resList.add(new ArrayList<>(temp));
            // 回溯
            temp.remove(temp.size() - 1);
            return;
        }

        start = i;
        // 遍历子节点
        for (int j = start + 1 ; j <= s.length(); j++) {
            if (isPalindrome(s, start, j)) {
                backtrace(start, j, s, temp);
            }
        }
        // 将结果移除
        temp.remove(temp.size() - 1);
    }

    /**
     * 判断处于区间[start, i)之间的字符串是否为回文串
     * @param s
     * @param start
     * @param i
     * @return
     */
    private boolean isPalindrome(String s, int start, int i) {
        if (i - start <= 1) return true;
        while (start < i) {
            if (s.charAt(start++) != s.charAt(--i)) {
                return false;
            }
        }
        return true;
    }

T93-复原IP地址

见LeetCode第93题复原IP地址

题目描述

给定一个只包含数字的字符串,请你用.分割出所有可能得IP地址。

我的题解

private List<String> resList = new ArrayList<>();

/**
 * 根据数字字符串 S 复原出所有可能的IP地址
 * @param s
 * @return
 */
public List<String> restoreIpAddresses(String s) {
    if (s == null || s.length() < 4) return resList;

    List<String> temp = new ArrayList<>();
    int start = 0;
    // 遍历所有可能的根节点
    for (int i = 1; i <= 3; i++) {
        // 判断左闭右开的区间[start, i)的数是否为合法的IP范围
        if (isValid(s, start, i)) {
            backtrace(start, i, s, temp);
        }
    }

    return resList;

}

/**
 * 递归寻找可能的 IP 组
 * @param start
 * @param i
 * @param s
 * @param temp
 */
private void backtrace(int start, int i, String s, List<String> temp) {
    // 将 [start, i) 之间的数字添加到temp
    temp.add(s.substring(start, i));
    // 判断返回条件:temp.size() == 4

    if (temp.size() > 4 || temp.size() == 4 && i != s.length()) {
        // 将当前数字移除
        temp.remove(temp.size() - 1);
        return;
    };
    if (temp.size() == 4) {
        // 将temp 使用 . 连接为一个IP
        StringBuilder sb = new StringBuilder();
        for (String str : temp) {
            sb.append(str).append('.');
        }
        resList.add(sb.substring(0, sb.length() - 1)); // 去掉末尾的 .
        // 将当前数字移除
        temp.remove(temp.size() - 1);
        return;
    }

    start = i;
    for (int j = start + 1; j <= s.length(); j++) {
        if (isValid(s, start, j)) {
            backtrace(start, j, s, temp);
        }
    }
    temp.remove(temp.size() - 1);
}

/**
 * 判断区间 [start, i) 之间的数字是否为合法的IP
 * @param s
 * @param start
 * @param i
 * @return
 */
private boolean isValid(String s, int start, int i) {
    if (start >= i || i - start > 3 || (s.charAt(start) == '0' && i - start > 1)) return false;
    int num = Integer.parseInt(s.substring(start, i));
    if (num >= 0 && num <= 255) {
        return true;
    }
    return false;
}

注意

  • 判断IP是否合法的时候,需要考虑 0 开头的IP段,例如023

  • 返回条件有两个:

    • 满足要求:temp.size() == 4并且结束索引正好在字符串的末尾i == s.length()
    • 不满足要求:temp.size() > 4 || temp.size() == 4 && i < s.length()