解空间极大问题通用策略
-
- 子集
-
- 第k个排列
-
- 每个元音包含偶数次的最长子字符串
-
- 全排列
-
- 全排列2
-
- 子集2
-
- 递增子序列
-
- 删除无效括号
-
- 组合
通常来说这类问题的解规模较大,很容易漏掉解,为此笔者提出一种解决问题的思路。
比如全排列问题,组合问题等。让我们以78.子集为例引入到情景中。
- 全排列 N ! N! N!
- 组合 N ! N! N!
- 子集 2 N 2^N 2N,每个元素可能存在或者不存在
要确保结果完整且不重复,有多种策略:
- 递归
- 回溯
- 字典
- 数学
- 状态压缩
I. 递归
递归不一定是递归函数,而是逐层次的把nums下一个数与前面的数融合起来。
比如:
vector<vector<int>> subsets(vector<int>& nums) {
if(!nums.size()) return {{}};
res.push_back({});
for(auto &c:nums)
{
auto _res = res;
for(auto &k :_res)
{
k.push_back(c);
res.push_back(k);
}
}
return res;
}
剪枝及技巧
很多时候dfs会经历不需要的中间状态,因此需要设计代码,避免不必要的递归。
时间复杂度: O ( N × 2 N ) \mathcal{O}(N \times 2^N) O(N×2N),生成所有子集,并复制到输出结果中。
空间复杂度: O ( N × 2 N ) \mathcal{O}(N \times 2^N) O(N×2N),这是子集的数量。
对于给定的任意元素,它在子集中有两种情况,存在或者不存在(对应二进制中的 0 和 1)。因此,N个数字共有 2 N 2^N 2N 个子集。
II. 回溯
注意:在大规模问题上,回溯法极容易超时。
正例:301.删除无效括号(Hard)
反例:416. 分割等和子集(Medium)(正确的解法是动态规划,而不是回溯)
回溯法(探索与回溯法)是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。
以该题为例,比如我们要在[1,2,3]
中找到所有子集,思路是这样的:
定义一个回溯方法 backtrack(first, curr),第一个参数为索引 first,第二个参数为当前子集 curr。
-
如果当前子集构造完成,将它添加到输出集合中。
-
否则,从 first 到 n 遍历索引 i。
- 将整数 nums[i] 添加到当前子集 curr。
- 继续向子集中添加整数:backtrack(i + 1, curr)。
- 从 curr 中删除 nums[i] 进行回溯。
int n;
vector<int> nums;
void backtrack(int first, vector<int> &curr)
{
if(first>=n) return;
for (int i = first; i < n; i++)
{
curr.push_back(nums[i]);
res.push_back(curr);
backtrack(i+1, curr);
curr.pop_back();
}
}
vector<vector<int>> subsets(vector<int>& nums)
{//回溯法
if(!nums.size()) return {{}};
this->n = nums.size();
this->nums = nums;
vector<int> curr;
backtrack(0, curr);
res.push_back({});
return res;
}
注意,这里的第六行
for (int i = first; i < n; i++)
非常关键,它保证各个子集是单调增的,避免了重复。
时间复杂度: O ( N × 2 N ) \mathcal{O}(N \times 2^N) O(N×2N),生成所有子集,并复制到输出结果中。
空间复杂度: O ( N × 2 N ) \mathcal{O}(N \times 2^N) O(N×2N),这是子集的数量。
对于给定的任意元素,它在子集中有两种情况,存在或者不存在(对应二进制中的 0 和 1)。因此,N个数字共有 2 N 2^N 2N 个子集。
III. 字典
该方法思路来自于Donald E. Knuth
将每个子集映射到长度为n的掩码中,其中第i位掩码nums[i]
为1
,表示第i个元素在子集中, 如果第i位掩码nums[i]
位0
,表明第i位元素不在子集中。
乍看起来生成二进制数很简单,但如何处理左边填充 0 是一个问题。因为必须生成固定长度的位掩码:例如 001
,而不是 1
。因此可以使用一些位操作技巧:
p
y
t
h
o
n
python
python:
nth_bit = 1 << n
for i in range(2**n):
# generate bitmask, from 0..00 to 1..11
bitmask = bin(i | nth_bit)[3:]
C + + C++ C++
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
n = len(nums)
output = []
for i in range(2**n, 2**(n + 1)):
# generate bitmask, from 0..00 to 1..11
bitmask = bin(i)[3:]
# append subset corresponding to that bitmask
output.append([nums[j] for j in range(n) if bitmask[j] == '1'])
return output
时间复杂度: O ( N × 2 N ) \mathcal{O}(N \times 2^N) O(N×2N),生成所有子集,并复制到输出结果中。
空间复杂度: O ( N × 2 N ) \mathcal{O}(N \times 2^N) O(N×2N),这是子集的数量。
对于给定的任意元素,它在子集中有两种情况,存在或者不存在(对应二进制中的 0 和 1)。因此,N个数字共有 2 N 2^N 2N 个子集。
IV. 数学
60.第k个排列
这里我们将题目稍微变形一下。来讲解数学知识如何发挥巨大的作用的。
给你一个排列
s
s
s,由数字1-9
组成,在不求全排列的前提下,返回它是正序的第k个排列。
示例 1:
输入: 213
输出: k = 3
解释:123全部的排列为 123, 132, 213, 231, 312, 321
要想解决本题,首先需要了解一个简单的结论:
对于 n n n 个不同的元素(例如数 1 , 2 , ⋯ , n 1,2,⋯,n 1,2,⋯,n),它们可以组成的排列总数目为 n ! n! n!。
对于给定的 n n n 和 k k k,我们不妨从左往右确定第 k k k 个排列中的每一个位置上的元素到底是什么。
我们首先确定排列中的首个元素 a 1 a_1 a1 。根据上述的结论,我们可以知道:
- 以 1 为
a
1
a_1
a1 的排列一共有
(
n
−
1
)
!
(n-1)!
(n−1)! 个;
以 2 为 $a_1 $ 的排列一共有 ( n − 1 ) ! (n-1)! (n−1)! 个;
⋯ ⋯ \cdots⋯ ⋯⋯
以 n 为 a 1 a_1 a1 的排列一共有 ( n − 1 ) ! (n-1)! (n−1)!个。
由于我们需要求出从小到大的第 k 个排列,因此:
如果
k
≤
(
n
−
1
)
!
k \leq (n-1)!
k≤(n−1)!,我们就可以确定排列的首个元素为 1;
如果
(
n
−
1
)
!
<
k
≤
2
⋅
(
n
−
1
)
!
(n-1)! < k \leq 2 \cdot (n-1)!
(n−1)!<k≤2⋅(n−1)!,我们就可以确定排列的首个元素为 2;
⋯
⋯
\cdots⋯
⋯⋯
如果
(
n
−
1
)
⋅
(
n
−
1
)
!
<
k
≤
n
⋅
(
n
−
1
)
!
(n-1) \cdot (n-1)! < k \leq n \cdot (n-1)!
(n−1)⋅(n−1)!<k≤n⋅(n−1)!,我们就可以确定排列的首个元素为 n。
因此,第 k 个排列的首个元素就是:
a
1
=
⌊
k
−
1
(
n
−
1
)
!
⌋
+
1
a
1
=
⌊
k
−
1
(
n
−
1
)
!
⌋
+
1
{a_1 = \lfloor \frac{k-1}{(n-1)!} \rfloor + 1} a_1 = \lfloor \frac{k-1}{(n-1)!} \rfloor + 1
a1=⌊(n−1)!k−1⌋+1a1=⌊(n−1)!k−1⌋+1
其中
⌊
x
⌋
\lfloor x \rfloor
⌊x⌋ 表示将 x 向下取整。
当我们确定了 a 1 a_1 a1后,如何使用相似的思路,确定下一个元素 a 2 a_2 a2呢?实际上,我们考虑以 a 1 a_1 a1为首个元素的所有排列:
第 k 个排列实际上就对应着这其中的第
k
′
=
(
k
−
1
)
m
o
d
(
n
−
1
)
!
+
1
k' = (k-1) \bmod (n-1)! + 1
k′=(k−1)mod(n−1)!+1
个排列。这样一来,我们就把原问题转化成了一个完全相同但规模减少 1的子问题.
代码:
class Solution {
public:
string getPermutation(int n, int k) {
vector<int> factorial(n);
factorial[0] = 1;
for (int i = 1; i < n; ++i) {
factorial[i] = factorial[i - 1] * i;
}
--k;
string ans;
vector<int> valid(n + 1, 1);
for (int i = 1; i <= n; ++i) {
int order = k / factorial[n - i] + 1;
for (int j = 1; j <= n; ++j) {
order -= valid[j];
if (!order) {
ans += (j + '0');
valid[j] = 0;
break;
}
}
k %= factorial[n - i];
}
return ans;
}
};
V. 状态压缩
状态压缩经典问题:1371. 每个元音包含偶数次的最长子字符串
给你一个字符串 s ,请你返回满足以下条件的最长子字符串的长度:每个元音字母,即 ‘a’,‘e’,‘i’,‘o’,‘u’ ,在子字符串中都恰好出现了偶数次。
示例 1:
输入:s = "eleetminicoworoep"
输出:13
解释:最长子字符串是 "leetminicowor" ,它包含 e,i,o 各 2 个,以及 0 个 a,u 。
示例 2:
输入:s = "leetcodeisgreat"
输出:5
解释:最长子字符串是 "leetc" ,其中包含 2 个 e 。
示例 3:
输入:s = "bcbcbc"
输出:6
解释:这个示例中,字符串 "bcbcbc" 本身就是最长的,因为所有的元音 a,e,i,o,u 都出现了 0 次。
提示:
1 <= s.length <= 5 x 10^5
- s 只包含小写英文字母。
一些子母问题(数字重复与不重复)
- 46 全排列
数字不重复,求全排列,回溯法
class Solution {
public:
void backtrack(vector<vector<int>>& res, vector<int>& output, int first, int len){
// 所有数都填完了
if (first == len) {
res.emplace_back(output);
return;
}
for (int i = first; i < len; ++i) {
// 动态维护数组
swap(output[i], output[first]);
// 继续递归填下一个数
backtrack(res, output, first + 1, len);
// 撤销操作
swap(output[i], output[first]);
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int> > res;
backtrack(res, nums, 0, (int)nums.size());
return res;
}
};
- 47 全排列2
数字重复,求全排列【此时一定要用hash表】
vector<vector<int>> res;
vector<int> nums;
vector<int> arr;
unordered_map<int,int> dict;
void dfs(int n)
{
if(n==0)
{
res.push_back(arr);
return;
}
for(auto &c:dict)
{
if(c.second>0)
{
arr.push_back(c.first);
c.second--;
dfs(n-1);
c.second++;
arr.pop_back();
}
}
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
if(!nums.size()) return {{}};
this->nums = nums;
for(auto &c:nums)
dict[c]++;
dfs(nums.size());
return res;
}
-
78 子集(上面作为例子讲了)
-
90 子集2
求包含重复元素的所有子集
class Solution {
public:
vector<pair<int, int>> data;
vector<vector<int>> res;
int n;
vector<int> tmp;
void dfs(int i) {
if (i == n) {
res.push_back(tmp);
return ;
}
dfs(i + 1);
//i是从n-1开始
for (int j = 0; j < data[i].second; j ++) {
tmp.push_back(data[i].first);
dfs(i + 1);
}
for (int j = 0; j < data[i].second; j ++) tmp.pop_back();
return ;
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
unordered_map<int, int> mp;//统计nums每个数字的个数
for (auto x : nums) {
mp[x] ++;
}
for (auto x : mp) {
data.push_back(x);//相当于把哈希表存到数组
}
n = data.size();
dfs(0);
return res;
}
};
给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是2。
示例:
输入: [4, 6, 7, 7]
输出: [[4, 6], [4, 7], [4, 6, 7], [4, 6, 7, 7], [6, 7], [6, 7, 7], [7,7], [4,7,7]]
说明:
- 给定数组的长度不会超过15。
- 数组中的整数范围是 [-100,100]。
- 给定数组中可能包含重复数字,相等的数字应该被视为递增的一种情况。
方法1:二进制枚举+哈希
我们用二进制0,1表示解选中或者不被选中。那么长度为
n
n
n的序列,对应有
2
n
2^n
2n种可能,对于序列去重,我们可以采用串哈希算法,(Rabin-Karp算法),即对于一个序列
a
0
,
a
1
,
.
.
.
,
a
n
−
1
{a_0,a_1,...,a_{n-1}}
a0,a1,...,an−1,我们可以认为是一个
max
(
a
i
)
+
1
\max(a_i)+1
max(ai)+1(记为b)进制的数。
f
(
a
)
=
∑
i
=
0
n
−
1
b
i
×
a
i
f(a)=\sum\limits_{i=0}^{n-1}b^i×a_i
f(a)=i=0∑n−1bi×ai
在实际使用种,我们发现这个编码可能非常的大,我们可以把它模上一个大素数
P
P
P,再映射到
i
n
t
int
int范围。
f
(
a
)
=
∑
i
=
0
n
−
1
b
i
×
a
i
(
m
o
d
P
)
f(a)=\sum\limits_{i=0}^{n-1}b^i×a_i(mod \ P)
f(a)=i=0∑n−1bi×ai(mod P)
Rabin-karp编码
vector<int> temp;
const int MIN_VAL = -100;
const int MAX_VAL = 100;
int getHash(int base, int mod)
{//这里的base为数组的最大值, min_val 为数组可能最小值,题目给出,为了避免负数的情况
//这里的mod 是一个大素数
//时间复杂度 O(N) 慎用
int hashVal = 0;
for(auto &c:temp)
{
hashVal = 1LL*hashVal*base % mod + (c - MIN_VAL + 1);
hashVal %= mod;
}
return hashVal;
}
int hashValue = getHash(MAX_VAL, (int)(1E9)+7);
数组编码
for(int i = 0; i < (1<<n);i++)
{
int mask = i;
for(int j = 0;j < n;j++)
{
if(j&1)
{
...
}
mask >>= 1;
}}
字符串编码
在该方法中,我们将字符串看成一个 base \textit{base} base 进制的数,它对应的十进制值就是哈希值。显然,两个字符串的哈希值相等,当且仅当这两个字符串本身相同。然而如果字符串本身很长,其对应的十进制值在大多数语言中无法使用内置的整数类型进行存储。因此,我们会将十进制值对一个大质数 mod \textit{mod} mod 进行取模。此时:
-
如果两个字符串的哈希值在取模后不相等,那么这两个字符串本身一定不相同;
-
如果两个字符串的哈希值在取模后相等,并不能代表这两个字符串本身一定相同。例如两个字符串的哈希值分别为 2 和 15,模数为 13,虽然 2 ≡ 15 ( m o d 13 ) 2 \equiv 15 ~~ (\bmod~13) 2≡15 (mod 13),但它们不相同。
一般来说,我们选取一个大于字符集大小(即字符串中可能出现的字符种类的数目)的质数作为 b a s e base base,再选取一个在字符串长度平方级别左右的质数作为 m o d mod mod,产生哈希碰撞的概率就会很低。
复杂度分析
假设序列的长度是 n n n。
- 时间复杂度: O ( 2 n ⋅ n ) O(2^n \cdot n) O(2n⋅n)。这里枚举所有子序列的时间代价是 O ( 2 n ) O(2^n) O(2n),每次检测序列是否合法和获取哈希值的时间代价都是 O ( n ) O(n) O(n).
- 空间复杂度:
O
(
2
n
)
O(2^n)
O(2n)。最坏情况下整个序列都是递增的,每个长度大于等于 2 的子序列都要加入答案,这里哈希表中要加入
2
n
2^n
2n
个元素,空间代价为 O ( 2 n ) O(2^n) O(2n),用一个临时的数组来存当前答案,空间代价为 O ( n ) O(n) O(n)。
方法2:递归+剪枝
这是一个递归枚举子序列的通用模板,即用一个临时数组 t e m p \rm temp temp 来保存当前选出的子序列,使用 c u r \rm cur cur 来表示当前位置的下标,在 dfs(cur, nums) 开始之前, [ 0 , c u r − 1 ] [0, {\rm cur} - 1] [0,cur−1]这个区间内的所有元素都已经被考虑过,而$ [{\rm cur}, n]$ 这个区间内的元素还未被考虑。在执行 dfs(cur, nums) 时,我们考虑 c u r {\rm cur} cur 这个位置选或者不选,如果选择当前元素,那么把当前元素加入到 t e m p \rm temp temp 中,然后递归下一个位置,在递归结束后,应当把 t e m p \rm temp temp的最后一个元素删除进行回溯;如果不选当前的元素,直接递归下一个位置。
当然,如果我们简单地这样枚举,对于每一个子序列,我们还需要做一次 O ( n ) O(n) O(n) 的合法性检查和哈希判重复,在执行整个程序的过程中,我们还需要使用一个空间代价 $O(2^n) $的哈希表来维护已经出现的子序列的哈希值。我们可以对选择和不选择做一些简单的限定,就可以让枚举出来的都是合法的并且不重复:
使序列合法的办法非常简单,即给「选择」做一个限定条件,只有当前的元素大于等于上一个选择的元素的时候才能选择这个元素,这样枚举出来的所有元素都是合法的
那如何保证没有重复呢?我们需要给「不选择」做一个限定条件,只有当当前的元素不等于上一个选择的元素的时候,才考虑不选择当前元素,直接递归后面的元素。因为如果有两个相同的元素,我们会考虑这样四种情况:
-
前者被选择,后者被选择
-
前者被选择,后者不被选择
-
前者不被选择,后者被选择
-
前者不被选择,后者不被选择
其中第二种情况和第三种情况其实是等价的,我们这样限制之后,舍弃了第二种,保留了第三种,于是达到了去重的目的。
class Solution {
public:
vector<int> temp;
vector<vector<int>> ans;
void dfs(int cur, int last, vector<int>& nums) {
if (cur == nums.size()) {//当前序号达到末尾才进行答案归纳
if (temp.size() >= 2) {
ans.push_back(temp);
}
return;
}
if (nums[cur] >= last) {
temp.push_back(nums[cur]);
dfs(cur + 1, nums[cur], nums);
temp.pop_back();
}
if (nums[cur] != last) {//只有前后元素不相同才考虑不选
dfs(cur + 1, last, nums);
}
}
vector<vector<int>> findSubsequences(vector<int>& nums) {
dfs(0, INT32_MIN, nums);
return ans;
}
};