文章目录
- 子集型回溯
- 时间复杂度计算
- 相关题目练习
- 784. 字母大小写全排列 https://leetcode.cn/problems/letter-case-permutation/
- 1601. 最多可达成的换楼请求数目 https://leetcode.cn/problems/maximum-number-of-achievable-transfer-requests/⭐⭐⭐⭐⭐
- 2397. 被列覆盖的最多行数 https://leetcode.cn/problems/maximum-rows-covered-by-columns/
- 306. 累加数 https://leetcode.cn/problems/additive-number/
- 2698. 求一个整数的惩罚数 https://leetcode.cn/problems/find-the-punishment-number-of-an-integer/
- 参考资料
子集型回溯
主要学习 分别从 输入 和 答案 去思考的两种代码模板。
例题1——78.子集
例题:78. 子集
先说这道题目的时间复杂度是 O ( N ∗ 2 N ) O(N*2^N) O(N∗2N)
代码模板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(N∗2N)
代码模板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. 递增子序列
重点学习这道题目的去重方法(树层去重)
代码模板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(n∗2n)。
对于 子集 ,答案的个数一共有 2 n 2^n 2n 个,因为每个元素都有选和不选两种状态。而对于每种方案,都对应着一个长度为 n 的树上路径长度。
对于 131. 分割回文串 这道题目,最坏情况下s 包含 n 个完全相同的字符,因此它的任意一种划分方法都满足要求。所以时间复杂度也是 O ( n ∗ 2 n ) O(n*2^n) O(n∗2n)。
相关题目练习
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;
}
}
不要被这种题目吓到,反正就是 选不选
就完事了,都枚举完之后再判断这个选的结果合不合理,合理就更新答案。
没必要在选的过程中去考虑选的合不合理。
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:回溯算法(六):子集+子集Ⅱ+递增子序列(经典子集问题)