回溯算法used数组
在回溯算法中,有些时候不能选取重复的元素值,如组合问题、子序列问题等。为了标记元素值是否出现过,需要对元素值进行标记,即需要一个used数组(此处所指的used数组包括数组、哈希set、哈希map等)。
- 按照used数组变量的位置可以分为全局变量used数组和局部变量used数组,
- 按照处理输入数组中重复元素的方式可以分为可重复used数组和不重复used数组
例如同样的输入数组nums[1,1,2]:
可重复used数组[0,1,1]表示nums中每一个元素(nums中共有3个元素1,1,2)目前是否被使用过;
而不重复used数组[0,1]则表示nums中每一个值(nums中共有两个值1,2)是否被使用过。
另外,问题中的输入数组有的可排序(如组合问题,子集问题),有的不可排序(如子序列问题)。可排序问题在回溯前将输入数组进行排序可以简化回溯过程,而不可排序问题只能耗费更多空间使用局部used数组。
那么何时使用何种used数组呢?先给出我的结论:
可排序问题 | 可重复used | 不重复used |
---|---|---|
全局used | √ | √ |
局部used | × | √ |
不可排序问题 | 可重复used | 不重复used |
---|---|---|
全局used | × | × |
局部used | × | √ |
1.可排序问题
1.1使用全局used数组
以LeetCode 40.组合总和II为例(子集问题和组合问题几乎一样在此不做举例)
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用一次。
说明: 所有数字(包括目标数)都是正整数。 解集不能包含重复的组合。
示例 1: 输入: candidates = [10,1,2,7,6,1,5], target = 8, 所求解集为: [ [1, 7], [1, 2, 5], [2, 6], [1, 1, 6] ]
示例 2: 输入: candidates = [2,5,2,1,2], target = 5, 所求解集为: [ [1,2,2], [5] ]
全局used数组用来标记此元素的值是否使用过,在此题中,位于同一树枝上的两个“等值”的元素可以重复选取,但位于同一树层的两个“等值”的元素不可以重复使用(关于树层和树枝的介绍可以参考代代码随想录的文章40.组合总和II)
组合问题的输入数组candidates可以被排序,在回溯中可以保证输入数组的有序性,这时使用全局可重复used数组。因此代码如下:
vector<vector<int>> result;
vector<int> path;
vector<bool> used;//used[i]直接表示candidates[i]是否使用过
void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
if (sum == target) {
result.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size()
&& sum + candidates[i] <= target; i++) {
// used[i - 1] == true,说明同一树支candidates[i - 1]使用过
// used[i - 1] == false,说明同一树层candidates[i - 1]使用过
// 要对同一树层使用过的元素进行跳过
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) {
continue;
}
sum += candidates[i];
path.push_back(candidates[i]);
used[i] = true;
backtracking(candidates, target, sum, i + 1);
used[i] = false;//全局used要进行回溯
sum -= candidates[i];
path.pop_back();
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
used =vector<bool> (candidates.size(), false);//这一步是创建可重复used
path.clear();
result.clear();
// 首先把给candidates排序,让其相同的元素都挨在一起。
//但子序列问题允许排列,因此不能使用全局used
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0);
return result;
}
同样,可以使用不重复全局used,代码如下:
vector<vector<int>> result;
vector<int> path;
//由于题目中1 <= candidates[i] <= 50,所以用used[1]表示1是否使用过,
//used[2]表示2是否使用过,依次类推,used[0]舍弃不用
int used[51]={0};
void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
if (sum == target) {
result.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
//这里的i>startIndex也可以写成i>0,实际上用了i>index的判断就不需要used数组了
//i>startIndex表示的就是i不是本树层第一个元素
if (i > startIndex && candidates[i] == candidates[i - 1] && used[candidates[i ]] == 0) {
continue;
}
sum += candidates[i];
path.push_back(candidates[i]);
used[candidates[i ]] = 1;
backtracking(candidates, target, sum, i + 1);
used[candidates[i ]] = 0;
sum -= candidates[i];
path.pop_back();
}
}
注意到,在可排序的问题中,用全局used确定元素是否重复使用过时,都使用了类似如下语句:
//由于已经排序了,所以只需要看前一个元素是否和本元素相同,
//如果相同并且前一个元素已经使用过 ,则本元素只能下一层的树枝下使用,而不能在本树层再次使用
//(此时的全局used数组used[i-1]是false(或用0表示),因为回到本层时回溯了used,used变回false)
if (i > startIndex && candidates[i] == candidates[i - 1] && used[candidates[i ]] == 0) {
continue;
}
所以实际上在全局used的情况下,重复used和不重复used区别并不大。全局的used数组都需要对used数组进行回溯。
1.2 使用局部used数组
局部used不需要回溯,每次进入新的递归层会创建新的used,used只需要记录本层情况即可,但会占用更多内存。
使用局部不重复used的代码如下:
vector<vector<int>> res;
vector<int> path;
int sum;
void backtracking(const vector<int>& candidates, int target,int startIndex){
if(sum==target){
res.push_back(path);
return;
}
//由于1 <= candidates[i] <= 50 ,所以设置51各变量(第0个索引不用)
int used[51]={0};
for(int i=startIndex;i<candidates.size()&&sum+candidates[i]<=target;++i){
if(used[candidates[i]]==1) continue;
path.push_back(candidates[i]);
sum+=candidates[i];
used[candidates[i]]=1;
backtracking(candidates,target,i+1);
path.pop_back();
sum-=candidates[i];
}
}
局部used基本只能使用不重复used,没有必要使用重复used。
2.不可排序问题
子序列问题不可以对输入数组进行排列,因此不能保证回溯时数组的有序性,在全局used使用的判断语句就不能使用了。
//不可排序问题不能使用下面的语句了
if (i > startIndex && candidates[i] == candidates[i - 1] && used[candidates[i ]] == 0) {
continue;
}
不可排序问题使用可重复used时会很麻烦,因为寻找和此元素相同值的元素时每次都要从头搜索(因为输入数组无序)。推荐使用局部不重复used。
如491. 递增子序列:
给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是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]。
给定数组中可能包含重复数字,相等的数字应该被视为递增的一种情况。
局部不重复used。代码如下:
vector<vector<int>> result;
vector<int> path;
void backtracking(const vector<int>& nums,int startIndex){
if(path.size()>=2){
result.push_back(path);
}
int used[201]={0};//题目给出的数字范围[-100,100],记录某一数字是否在同一树层使用过
for(int i=startIndex;i<nums.size();++i){
if((!path.empty()&&nums[i]<path.back())||used[nums[i]+100]==1)
continue;
path.push_back(nums[i]);
used[nums[i]+100]=1;
backtracking(nums,i+1);
path.pop_back();
//used[nums[i]+100]=0;局部used不需要回溯
}
}
vector<vector<int>> findSubsequences(vector<int>& nums) {
backtracking(nums,0);
return result;
}
3.总结
一般来说,局部不重复used可以稳定的去重,对可排序问题和不可排序问题都适用,只是需要更多内存。而可排序问题可以用全局used节省一些内存。可排序问题在采用全局uesd的情况下更推荐可重复used数组(一般来说输入数组的size小于输入数组数值范围)。事实上,只有在排序的时候才可以用可重复used,因为这样才能比较前一个元素是否和本元素等。
在去重逻辑上,局部used由于只记录本层元素并且不回溯,因此判断used[i]为1(true)时表示nums[i]使用过,要continue;而全局used(只用于可排序问题)记录所有树枝元素,所以需要回溯,因此当i > startIndex && candidates[i] == candidates[i - 1]时,used[i-1]=0表示nums[i-1]已经使用过并且回溯时从1变为0(全局可重复used和全局不重复used类似);