【算法总结】——子集型回溯

子集型回溯

在这里插入图片描述
主要学习 分别从 输入答案 去思考的两种代码模板。

例题1——78.子集

例题:78. 子集
在这里插入图片描述

先说这道题目的时间复杂度是 O ( N ∗ 2 N ) O(N*2^N) O(N2N)

代码模板1

站在 答案 的角度思考
枚举第一个数选谁
枚举第二个数选谁
每个节点都是答案

class Solution {
    List<List<Integer>> ans = new ArrayList();
    List<Integer> t = new ArrayList();

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

    public void dfs(int startIndex, int[] nums) {
        ans.add(new ArrayList(t));
        if (startIndex == nums.length) {
            return;
        }
        for (int i = startIndex; i < nums.length; ++i) {
            t.add(nums[i]);
            dfs(i + 1, nums);
            t.remove(t.size() - 1);
        }
    }
}

代码模板2

站在 输入 的角度思考
每个数可以在子集中(选)
也可以不在子集中(不选)
叶子是答案

class Solution {
    List<List<Integer>> ans = new ArrayList();
    List<Integer> t = new ArrayList();

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

    public void dfs(int i, int[] nums) {
        if (i == nums.length) {
            ans.add(new ArrayList(t));	// 当每个位置都经历了选和不选之后,加入答案
            return;
        }
        dfs(i + 1, nums);	// 不选直接下一个
        t.add(nums[i]);
        dfs(i + 1, nums);	// 选了之后递归下一个
        t.remove(t.size() - 1);
    }
}

个人感觉代码模板2更好理解一些。

这道题目没有剪枝操作是因为:在这个问题中,不存在无效的搜索路径。

例题2——131.分割回文串

https://leetcode.cn/problems/palindrome-partitioning/
在这里插入图片描述

所谓分割字符串,其实就是字符之间的逗号选不选的问题,因此这也是子集型回溯。

这道题目的时间复杂度也是 O ( N ∗ 2 N ) O(N*2^N) O(N2N)

代码模板1

class Solution {
    boolean[][] st;
    List<List<String>> ans = new ArrayList();
    List<String> t = new ArrayList();

    public List<List<String>> partition(String s) {
        int n = s.length();
        st = new boolean[n][n];		// 提前计算出dp[i][j]表示从i~j是否为回文串
        for (int i = n - 1; i >= 0; --i) {
            for (int j = i; j < n; ++j) {
                if (i == j) st[i][j] = true;
                else if (j == i + 1) st[i][j] = s.charAt(i) == s.charAt(j);
                else st[i][j] = st[i + 1][j - 1] && s.charAt(i) == s.charAt(j);
            }
        }
        dfs(s, 0);
        return ans;
    }

    public void dfs(String s, int startIndex) {
        if (startIndex == s.length()) {
            ans.add(new ArrayList(t));
            return;
        }
        for (int i = startIndex; i < s.length(); ++i) {
            if (st[startIndex][i]) {	// 从startIndex到当前i是回文串
                t.add(s.substring(startIndex, i + 1));
                dfs(s, i + 1);
                t.remove(t.size() - 1);
            }
        }
    }
}

代码模板2

class Solution {
    List<List<String>> ans = new ArrayList();
    List<String> t = new ArrayList();

    public List<List<String>> partition(String s) {
        dfs(s, 0, 0);
        return ans;
    }

    public void dfs(String s, int i, int last) {
        if (i == s.length()) {	// 要选的字符已经选完了
            ans.add(new ArrayList(t));
            return;
        }
        if (i + 1 < s.length()) dfs(s, i + 1, last);	// 不选当前字符
        if (check(s, last, i)) {	// 如果当前字符可以被选择
            t.add(s.substring(last, i + 1));
            dfs(s, i + 1, i + 1);	// 由于当前字符要被选择了,所以下一个回文子串从i + 1开始
            t.remove(t.size() - 1);
        }
    }

    public boolean check(String s, int l, int r) {
        while (l < r) {
            if (s.charAt(l++) != s.charAt(r--)) return false;
        }
        return true;
    }
}

补充:怎么判断回文串

双指针

https://leetcode.cn/problems/find-first-palindromic-string-in-the-array/
两边各设置一个指针,分别为 l 和 r,逐步移动并比较是否相同。

或者叫 中心拓展法 ,从中间开始逐步向两边移动并比较。

class Solution {
public:
    string firstPalindrome(vector<string>& words) {
        // 判断字符串是否回文
        auto isPalindrome = [](const string& word) -> bool {
            int n = word.size();
            int l = 0, r = n - 1;
            while (l < r) {
                if (word[l] != word[r]) {
                    return false;
                }
                ++l;
                --r;
            }
            return true;
        };
        
        // 顺序遍历字符串数组,如果遇到回文字符串则返回,未遇到则返回空字符串
        for (const string& word: words) {
            if (isPalindrome(word)) {
                return word;
            }
        }
        return "";
    }
};
dp提前处理

https://leetcode.cn/problems/palindromic-substrings/

class Solution {
    public int countSubstrings(String s) {
        int n = s.length(), ans = 0;
        boolean[][] dp = new boolean[n][n];
        for (int i = n - 1; i >= 0; --i) {
            for (int j = i; j < n; ++j) {
                dp[i][j] = s.charAt(i) == s.charAt(j);
                if (j > i + 1) dp[i][j] &= dp[i + 1][j - 1];
                if (dp[i][j]) ++ans;
            }
        }
        return ans;
    }
}

提前处理的时间复杂度是 O(N^2),之后每次查询的时间复杂度是O(1).

例题3——491. 递增子序列

重点学习这道题目的去重方法(树层去重

491. 递增子序列
在这里插入图片描述

代码模板1——选不选

每次递归是探索下一个数字选不选。

class Solution {
    List<List<Integer>> ans = new ArrayList();
    List<Integer> t = new ArrayList();

    public List<List<Integer>> findSubsequences(int[] nums) {
        // 参数列表依次是当前元素下标,上个被选的元素大小,数组
        dfs(0, Integer.MIN_VALUE, nums);    
        return ans;
    }

    public void dfs(int i, int last, int[] nums) {
        if (i == nums.length) {
            if (t.size() >= 2) ans.add(new ArrayList(t));
            return;
        }
        if (nums[i] >= last) {
            t.add(nums[i]);
            dfs(i + 1, nums[i], nums);
            t.remove(t.size() - 1);
        }
        if (nums[i] != last) dfs(i + 1, last, nums);	// 去重,对应”当前者被选择时后者也一定被选择“
    }
}

在这里插入图片描述

代码模板2——选哪个

每次递归是探索下一次选择选哪个数字。

class Solution {
    List<List<Integer>> ans = new ArrayList();
    List<Integer> t = new ArrayList();

    public List<List<Integer>> findSubsequences(int[] nums) {
        // 参数列表依次是当前元素下标,上个被选的元素大小,数组
        dfs(0, Integer.MIN_VALUE, nums);    
        return ans;
    }

    public void dfs(int startIndex, int last, int[] nums) {
        if (t.size() >= 2) ans.add(new ArrayList(t));
        boolean[] used = new boolean[201];  // 用于树层去重,标记这一层某个数字值是否被使用过
        for (int i = startIndex; i < nums.length; ++i) {	// 遍历,看这一层选哪个
            if (nums[i] >= last && !used[nums[i] + 100]) {
                used[nums[i] + 100] = true;
                t.add(nums[i]);
                dfs(i + 1, nums[i], nums);
                t.remove(t.size() - 1);
            }
        }
    }
}

时间复杂度计算

这三道题目的时间复杂度都是 O ( n ∗ 2 n ) O(n*2^n) O(n2n)

对于 子集 ,答案的个数一共有 2 n 2^n 2n 个,因为每个元素都有选和不选两种状态。而对于每种方案,都对应着一个长度为 n 的树上路径长度。

对于 131. 分割回文串 这道题目,最坏情况下s 包含 n 个完全相同的字符,因此它的任意一种划分方法都满足要求。所以时间复杂度也是 O ( n ∗ 2 n ) O(n*2^n) O(n2n)

相关题目练习

784. 字母大小写全排列 https://leetcode.cn/problems/letter-case-permutation/

https://leetcode.cn/problems/letter-case-permutation/
在这里插入图片描述
还是从选不选的角度去考虑,只不过是在选大写还是小写,以及只有当前字符是字母时才需要考虑大小写,其它字符保持不选必须选择。

class Solution {
    List<String> ans = new LinkedList();
    StringBuilder t = new StringBuilder();

    public List<String> letterCasePermutation(String s) {
        dfs(0, s);
        return ans;
    }

    public void dfs(int i, String s) {
        if (i == s.length()) {
            ans.add(new String(t));
            return;
        }
        char ch = s.charAt(i);
        if (Character.isDigit(ch)) {
            t.append(ch);
            dfs(i + 1, s);
            t.deleteCharAt(t.length() - 1);
        } else {
            t.append(Character.toUpperCase(ch));
            dfs(i + 1, s);
            t.deleteCharAt(t.length() - 1);
            t.append(Character.toLowerCase(ch));
            dfs(i + 1, s);
            t.deleteCharAt(t.length() - 1);
        }
    }
}

1601. 最多可达成的换楼请求数目 https://leetcode.cn/problems/maximum-number-of-achievable-transfer-requests/⭐⭐⭐⭐⭐

https://leetcode.cn/problems/maximum-number-of-achievable-transfer-requests/
在这里插入图片描述

输入:n = 5, requests = [[0,1],[1,0],[0,1],[1,2],[2,0],[3,4]]
输出:5
解释:请求列表如下:
从楼 0 离开的员工为 x 和 y ,且他们都想要搬到楼 1 。
从楼 1 离开的员工为 a 和 b ,且他们分别想要搬到楼 2 和 0 。
从楼 2 离开的员工为 z ,且他想要搬到楼 0 。
从楼 3 离开的员工为 c ,且他想要搬到楼 4 。
没有员工从楼 4 离开。
我们可以让 x 和 b 交换他们的楼,以满足他们的请求。
我们可以让 y,a 和 z 三人在三栋楼间交换位置,满足他们的要求。
所以最多可以满足 5 个请求。

提示:

1 <= n <= 20
1 <= requests.length <= 16
requests[i].length == 2
0 <= from_i, to_i < n

思路

从选不选的角度去考虑,
枚举到结束时判断这种选法是否合理,如果合理就更新答案。

代码

class Solution {
    int m, ans = 0;
    int[][] requests;
    int[] cnt;

    public int maximumRequests(int n, int[][] requests) {
        this.m = requests.length;
        this.requests = requests;
        cnt = new int[n];
        dfs(0, 0);
        return ans;
    }

    // i表示枚举到了第i个requests,c表示到此时选了一个requests
    public void dfs(int i, int c) {
        if (i == m) {
            if (check()) ans = Math.max(ans, c);
            return;
        }
        // 不选i
        dfs(i + 1, c);      
        // 选i
        cnt[requests[i][0]]--;
        cnt[requests[i][1]]++;
        dfs(i + 1, c + 1);
        cnt[requests[i][0]]++;
        cnt[requests[i][1]]--;
    }

    public boolean check() {
        // 检查数组里的元素是否全为0
        return Arrays.stream(cnt).filter(x -> x == 0).count() == cnt.length;
    }
}

不要被这种题目吓到,反正就是 选不选 就完事了,都枚举完之后再判断这个选的结果合不合理,合理就更新答案。
没必要在选的过程中去考虑选的合不合理。

参考资料:https://leetcode.cn/problems/maximum-number-of-achievable-transfer-requests/solutions/2183573/ling-shen-zi-ji-xing-hui-su-ke-hou-zuo-y-zgol/

2397. 被列覆盖的最多行数 https://leetcode.cn/problems/maximum-rows-covered-by-columns/

https://leetcode.cn/problems/maximum-rows-covered-by-columns/

在这里插入图片描述

提示:
m == mat.length
n == mat[i].length
1 <= m, n <= 12
mat[i][j] 要么是 0 要么是 1 。
1 <= cols <= n

解法1——选不选,枚举

思路

枚举每一列,看选还是不选,枚举结束后计算这种选法被覆盖的行数是多少,更新答案。

代码1——普通回溯
class Solution {
    int m, n, ans = 0;
    int[][] matrix;
    boolean[] choose;

    public int maximumRows(int[][] matrix, int numSelect) {
        m = matrix.length;
        n = matrix[0].length;
        choose = new boolean[n];    // 标记哪一列被选择了
        this.matrix = matrix;
        dfs(0, numSelect);
        return ans;
    }

    public void dfs(int i, int numSelect) {
        if (i == n || numSelect < 0) {
            if (i == n && numSelect == 0) {
                ans = Math.max(ans, op());
            }
            return;
        }
        // 不选
        dfs(i + 1, numSelect);
        // 选
        choose[i] = true;
        dfs(i + 1, numSelect - 1);
        choose[i] = false;
    }
	
	// 计算某种选法时 被列覆盖的行数
    public int op() {
        int res = 0, v = 1;
        for (int i = 0; i < m; ++i) {
            v = 1;
            for (int j = 0; j < n; ++j) {
                if (matrix[i][j] == 1 && !choose[j]) {  
                    v = 0;  // 如果是1而且没被选择,那这一行就不加了
                    break;
                }
            }
            res += v;
        }
        return res;
    }
}
代码2——二进制枚举

这两种枚举方式的运行时间是差不多的。

class Solution {
    public int maximumRows(int[][] matrix, int numSelect) {
        int m = matrix.length, n = matrix[0].length, ans = 0;
        int[] masks = new int[m];
        // 处理出每一行的二进制表示
        for (int i = 0 ; i < m; ++i) {
            for (int j = 0; j < n; ++j) {
                masks[i] |= matrix[i][j] << j;
            }
        }
        // 枚举所有选 列 的情况
        for (int set = 1; set < 1 << n; ++set) {
            // 如果不是 k 个列,就continue
            if (Integer.bitCount(set) != numSelect) continue;
            int res = 0;
            for (int row: masks) {
                // 要求选出的列包含这一行的所有1的列
                res += row == (set & row)? 1: 0;
            }
            ans = Math.max(ans, res);
        }
        return ans;
    }
}

解法2——Gosper’s Hack⭐⭐⭐

上面的代码有很多无效枚举,即大小不等于 cols 的集合,如何优化呢?
举个例子:
在这里插入图片描述
我们其实只需要枚举二进制表示中有 4 个 1 的集合。

我们想要找到一种方法可以直接枚举下一种符合条件的集合。
在这里插入图片描述
我们先手动模拟一下怎么找下一个数字的过程。
首先从右往左找到第一个 01 ,将其变成 10
然后将后续的所有 1 挪到最后面即可。

lowbit(x) = x & (~x + 1) = x & (-x)。

将从 101110 到 110011 的转移分成两部分:
第一部分——010变成011,实际上就是 x + lowbit(x)
第二部分——公式是:x ^ (x + lowbit(x)) // lowbit(x) >> 2

通过使用 Gosper's Hack,我们可以在 O ( 1 ) O(1) O(1) 的时间内找到下一个大小为 cols 的集合。(这就是一种已知当前集合,求下一集合的方法)

class Solution {
    public int maximumRows(int[][] matrix, int numSelect) {
        int m = matrix.length, n = matrix[0].length, ans = 0;
        int[] masks = new int[m];
        // 处理出每一行的二进制表示
        for (int i = 0 ; i < m; ++i) {
            for (int j = 0; j < n; ++j) {
                masks[i] |= matrix[i][j] << j;
            }
        }
        // 枚举所有选 列 的情况
        for (int set = (1 << numSelect) - 1; set < 1 << n; set = next(set)) {
            // 如果不是 k 个列,就continue
            if (Integer.bitCount(set) != numSelect) continue;
            int res = 0;
            for (int row: masks) {
                // 要求选出的列包含这一行的所有1的列
                res += row == (set & row)? 1: 0;
            }
            ans = Math.max(ans, res);
        }
        return ans;
    }

    public int next(int x) {
        int lb = x & (-x);
        int left = x + lb;
        int right = (x ^ (x + lb)) / lb >> 2;
        return left + right;    // 左右两边拼起来,用 left | right 也可以
    }
}

306. 累加数 https://leetcode.cn/problems/additive-number/

https://leetcode.cn/problems/additive-number/

在这里插入图片描述

提示:
1 <= num.length <= 35
num 仅由数字(0 - 9)组成

分割数字的操作类似于 https://leetcode.cn/problems/palindrome-partitioning/ ,将分割结果作为一个序列,判断其是否是一个累加序列,更新结果即可。

由于序列很长,因此在判断是否为累加序列时需要使用字符串加法。关于高精度加法可见:【算法基础】1.4 高精度(模拟大数运算:整数加减乘除)

class Solution {
    List<String> nums = new ArrayList();
    boolean ans = false;
    String s;
    int n;

    public boolean isAdditiveNumber(String num) {
        s = num;
        n = num.length();
        dfs(0, 0, 0);
        return ans;
    }

    public void dfs(int i, int lastId, int lastlastId) {
        if (ans) return;    // 如果已经找到答案了就不再继续回溯了
        if (i >= n) {
            if (lastId == n && nums.size() >= 3) {
                ans |= test();
            }
            return;
        }
        if (s.charAt(lastId) == '0' && i > lastId) return;  // 不能有前导零
        // 不选择结束
        dfs(i + 1, lastId, lastlastId);
        if (lastlastId != 0 && i - lastId < lastId - lastlastId - 1) return;       // 数字不够大就别选
        // 选择结束当前数字
        nums.add(s.substring(lastId, i + 1));
        dfs(i + 1, i + 1, lastId);
        nums.remove(nums.size() - 1);
    }

    // 判断该序列是否为累加序列
    public boolean test() {
        for (int i = 2; i < nums.size(); ++i) {
            String a = nums.get(i), b = nums.get(i - 1), c = nums.get(i - 2);
            if (a.length() > Math.max(b.length(), c.length()) + 1 || a.length() < Math.min(b.length(), c.length())) return false;
            if (!a.equals(sum(b, c))) return false;
        }
        return true;
    }

    // 大数相加
    String sum(String a, String b) {
        int carry = 0;
        StringBuilder res = new StringBuilder();
        for (int i = a.length() - 1, j = b.length() - 1; i >= 0 || j >= 0 || carry != 0; --i, --j) {
            if (i >= 0) carry += a.charAt(i) - '0';
            if (j >= 0) carry += b.charAt(j) - '0';
            res.append((char)('0' + carry % 10));
            carry /= 10;
        }
        return res.reverse().toString();
    }
}

2698. 求一个整数的惩罚数 https://leetcode.cn/problems/find-the-punishment-number-of-an-integer/

https://leetcode.cn/problems/find-the-punishment-number-of-an-integer/
在这里插入图片描述

class Solution {
    public static boolean[] st = new boolean[1001];
    public static int[] ans = new int[1001];
    // 预先处理出1000以内的所有惩罚数
    static {
        for (int i = 1; i <= 1000; ++i) {
            ans[i] = ans[i - 1];
            if (check(i)) {
                st[i] = true;
                ans[i] += i * i;
            }
        }
    }
    
    public int punishmentNumber(int n) {
        return ans[n];
    }
    
    public static boolean check(int k) {
        int sq = k * k;
        return dfs(Integer.toString(sq), k, 0, 1);
    }
    
    // 平方后的字符串,平方前的数字,i和j表示所取的字符串的范围
    public static boolean dfs(String s, int k, int i, int j) {
        if (k == 0 && i == s.length()) return true;
        if (k < 0 || i > s.length() - 1) return false;
        boolean a = false;
        if (j + 1 <= s.length()) a = dfs(s, k, i, j + 1);
        boolean b = dfs(s, k - Integer.parseInt(s.substring(i, j)), j, j + 1);
        return a || b;
    }
}

我也记不清我当时 dfs 的过程是怎么写的了,反正是用 回溯 来判断一个数字是否是惩罚数。


参考资料

https://www.bilibili.com/video/BV1mG4y1A7Gu/
回溯注意点:回溯时间复杂度的计算与剪枝操作
【算法总结】——组合型回溯
【算法总结】——排列型回溯
DAY31:回溯算法(六):子集+子集Ⅱ+递增子序列(经典子集问题)

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Wei *

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值