回溯算法专题

方法区分

回溯、深度优先搜索、分治法、递归以及动态规划之间经常搞不清楚。区分起来大概如下:
分治法:分而治之,将问题拆分为具有较小规模的子问题,再将子问题的优化解合并,从而得到原问题的解。
递归:简单来说就是自己调用自己,比如函数调用自身。递归通常涉及到压栈出栈等操作,可以看成是一种特殊的分治。
动态规划:不仅具有优化子结构,而且子问题之间还要有重叠性。也就是说,动态规划不仅要满足分治法的条件,而且子问题之间往往还有交叉,比如爬楼梯问题和斐波那契数列,如果仍然采用分治递归的方法,往往会重复计算之前已经计算过的子问题,浪费大量算力和时间。此时,如果采用动态规划的方式,记录之前计算过的子问题的解,然后利用之前存储过的子问题解来迭代计算下一步子问题的解,就可以大大减少计算量。
动态规划常用于求解子序列、子数组、子集合、矩阵等问题,比较困难的是找状态量(代价表示),状态量常设置成以XXX结尾,比如LeetCode53. 最大子数组和 这个一维动态规划问题的状态量就设置为“以第 i 个数结尾的「连续子数组的最大和」”,设置状态量之后,就可以找状态转移方程(代价方程)了,通过状态转移方程来迭代求解。当状态变量比较少时,可以不用数组存储,用变量来维护,实现滚动数组
回溯与深度优先搜索DFS:这两者常常放到一起,回溯法也可以叫做回溯搜索法,它是一种搜索的方式。回溯是递归的副产品,只要有递归就会有回溯。回溯是一种枚举、试错的思想,程序遇到不符合条件的情形就往回走,直接找到答案。回溯是一种特殊的DFS,但DFS不一定是回溯。深度优先是算法思想,比如二叉树的前中后序遍历就是一种深度优先搜索。回溯是具体实现过程,DFS就是一个最典型的回朔法的具体应用。
下面重点总结一下回溯算法:

回溯算法理论基础

回溯算法关键在于:不合适就退回上一步,然后通过约束条件, 减少时间复杂度.
【做题经验】
往往排列组合子集问题会用到回溯,有时候将输入数组进行排序可以简化回溯过程中的剪枝操作。
回朔法的思想:
回朔法的重要思想在于, 通过枚举法,对所有可能性进行遍历。 但是枚举的顺序是 一条路走到黑,发现黑之后,退一步,再向前尝试没走过的路。直到所有路都试过。因此回朔法可以简单的理解为: 走不通就退一步的枚举法就叫回朔法。而这里回退点也叫做回朔点。
回朔法实现的三大技术关键点分别是:

  1. 一条路走到黑
  2. 回退一步
  3. 另寻他路

用代码实现关键点的方法:
4. for 循环
5. 递归

• for循环的作用在于另寻他路: 你可以用for循环可以实现一个路径选择器的功能,该路径选择器可以逐个选择当前节点下的所有可能往下走下去的分支路径。 例如: 现在你走到了节点a,a就像个十字路口,你从上面来到达了a,可以继续向下走。若此时向下走的路有i条,那么你肯定要逐个的把这i条都试一遍才行。而for的作用就是可以让你逐个把所有向下的i个路径既不重复,也不缺失的都试一遍
• 递归可以实现一条路走到黑和回退一步: 一条路走到黑: 递归意味着继续向着for给出的路径向下走一步。 如果我们把递归放在for循环内部,那么for每一次的循环,都在给出一个路径之后,进入递归,也就继续向下走了。直到递归出口(走无可走)为止。 那么这就是一条路走到黑的实现方法。 递归从递归出口出来之后,就会实现回退一步。
因此for循环和递归配合可以实现回朔: 当递归从递归出口出来之后。上一层的for循环就会继续执行了。而for循环的继续执行就会给出当前节点下的下一条可行路径。而后递归调用,就顺着这条从未走过的路径又向下走一步,这就是回朔。

回朔算法Python流程模板:

def backward():   
    if (回朔点):# 这条路走到底的条件。也是递归出口
        保存该结果
        return 
              
    else:
        for route in all_route_set :  逐步选择当前节点下的所有可能route            
            if 剪枝条件:
                剪枝前的操作
                return   #不继续往下走了,退回上层,换个路再走
                            
            else#当前路径可能是条可行路径    
                保存当前数据  #向下走之前要记住已经走过这个节点了。例如push当前节点        
                self.backward() #递归发生,继续向下走一步了。                
                回朔清理  # 该节点下的所有路径都走完了,清理堆栈,准备下一个递归。例如弹出当前节点

这里剪枝操作指的是: 对于有些问题,你走着走着,若某种情况发生了,你就已经直到不能继续往下走了,再走也没有用了。而这个情况就被称之为剪枝条件。
而DFS就是一个最典型的回朔法的应用。

回溯法例题精讲

题目:LeetCode39.组合总和
题目描述:
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。

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

【解答】
视频讲解-39.组合总和
本题与77.组合问题216.组合总和III的区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
本题搜索的过程抽象成树形结构如下:

注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
而在回溯算法77.组合问题216.组合总和III中都可以知道要递归K层,因为要取k个元素的组合。

回溯三部曲

• 递归函数参数
这里依然是定义两个全局变量,二维数组result存放结果集,数组path存放符合条件的结果。(这两个变量可以作为函数参数传入)
首先是题目中给出的参数,集合candidates, 和目标值target。
此外还定义了int型的sum变量来统计单一结果path里的总和,其实这个sum也可以不用,用target做相应的减法就可以了,最后如何target==0就说明找到符合的结果了,但为了代码逻辑清晰,依然用了sum。
本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?
如果是一个集合来求组合的话,就需要startIndex,例如回溯算法77.组合问题216.组合总和III
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:回溯算法17.电话号码的字母组合
注意以上只是说求组合的情况,如果是排列问题,又是另一套分析的套路,后面在讲解排列的时候再重点介绍
代码如下:

vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex)

• 递归终止条件
在如下树形结构中:

从叶子节点可以清晰看到,终止只有两种情况,sum大于target和sum等于target。
sum等于target的时候,需要收集结果,代码如下:

if (sum > target) {
    
    
    return;
}
if (sum == target) {
    
    
    result.push_back(path);
    return;
}

• 单层搜索的逻辑
单层for循环依然是从startIndex开始,搜索candidates集合。
注意本题和77.组合问题216.组合总和III的一个区别是:本题元素为可重复选取的
如何重复选取呢,看代码,注释部分:

for (int i = startIndex; i < candidates.size(); i++) {
    
    
    sum += candidates[i];
    path.push_back(candidates[i]);
    backtracking(candidates, target, sum, i); // 关键点:不用i+1了,表示可以重复读取当前的数
    sum -= candidates[i];   // 回溯
    path.pop_back();        // 回溯
}

按照回溯算法理论基础中给出的模板,可以写出如下C++完整代码:

// 版本一
class Solution {
    
    
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
    
    
        if (sum > target) {
    
    
            return;
        }
        if (sum == target) {
    
    
            result.push_back(path);
            return;
        }
        for (int i = startIndex; i < candidates.size(); i++) {
    
    
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i); // 不用i+1了,表示可以重复读取当前的数
            sum -= candidates[i];
            path.pop_back();
        }
    }
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
    
    
        result.clear();
        path.clear();
        backtracking(candidates, target, 0, 0);
        return result;
    }
};

剪枝优化

从上面的树形结构图以及上面版本一的代码可以看到,对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。
其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。
那么可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历
如图

for循环剪枝代码如下:

for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)

整体代码如下:(注意注释的部分)

class Solution {
    
    
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
    
    
        if (sum == target) {
    
    
            result.push_back(path);
            return;
        }

        // 如果 sum + candidates[i] > target 就终止遍历
        for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
    
    
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i);
            sum -= candidates[i];
            path.pop_back();

        }
    }
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
    
    
        result.clear();
        path.clear();
        sort(candidates.begin(), candidates.end()); // 需要排序
        backtracking(candidates, target, 0, 0);
        return result;
    }
};

总结

本题与回溯算法77.组合问题216.组合总和III有两点不同:
• 组合没有数量要求
• 元素可无限重复选取
针对这两个问题,上面都做了详细的分析。
并且给出了对于组合问题,什么时候用startIndex,什么时候不用,并用回溯算法17.电话号码的字母组合做了对比。
最后还给出了本题的剪枝优化,这个优化如果是初学者的话并不容易想到。
在求和问题中,排序之后加剪枝是常见的套路!
要通过不断作对比,分析其差异,然后给出代码解决的方法,这样才能彻底理解题目的本质与难点。

刷题顺序

按照如下顺序刷力扣上的题目,相信会帮你在学习回溯算法的路上少走很多弯路:
关于回溯算法,你该了解这些!
• 组合问题
o 77.组合
o 216.组合总和III
o 17.电话号码的字母组合
o 39.组合总和
o 40.组合总和II
• 分割问题
o 131.分割回文串
o 93.复原IP地址
• 子集问题
o 78.子集
o 90.子集II
• 排列问题
o 46.全排列
o 47.全排列II
• 棋盘问题
o 51.N皇后
o 37.解数独
• 其他
o 491.递增子序列
o 332.重新安排行程
回溯算法总结篇
这里参考了代码随想录程序员Carl的技术分享,建议可以按照代码随想录刷题路线来刷,并提供PDF下载,刷题路线同时也开源在Github上,你会发现详见很晚!

补充:进阶版题目:
79. 单词搜索(2020年小米秋招题目)

回溯经典例题笔记

回溯通用递归框架

这一类题通常的递归式函数框架:

vector<vector<int>> ans;
vector<int> res;
void DFS(vector<int>& nums, int index, int target)
{
    
    
    //满足题目条件则加入答案
	if(index == nums.size()) 或者if(target == 0) 
	{
    
    
		ans.emplace_back(res);
		return;
	}
	//不满足题目条件,并且到了容器边界或条件边界,则进行剪枝
	if(target < nums[index])或者if(index == nums.size()) 
	{
    
    
		return;
	}
	//不选当前元素
	DFS(nums, index+1, target);
	//选择当前元素
	res.emplace_back(nums[index]);
	DFS(nums, index+1, target-nums[index]);
	res.pop_back(); //记得回溯时出当前元素
}
调用:dfs(nums, 0, target);

17. 电话号码的字母组合
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例 1:
输入:digits = “23”
输出:[“ad”,“ae”,“af”,“bd”,“be”,“bf”,“cd”,“ce”,“cf”]
【思路】
回溯,先把第一个首字母压入字符串,再递归遍历压入第二个字符串中的一个字符,……,最后出该首字符。依次压入后面的字符,并递归遍历。
回溯框架:进行下一步之前加入当前节点,下一步递归过之后,要把当前节点从路径中删除,所以才是“回溯”。不然只增不减,结果路径中的值就会越来越多了
【代码】

//注意第一个键值为char类型,每个键值对用花括号单独括出来
    unordered_map<char,string> mp={
    
    {
    
    '2',"abc"},{
    
    '3',"def"},{
    
    '4',"ghi"},
        {
    
    '5',"jkl"},{
    
    '6',"mno"},{
    
    '7',"pqrs"},{
    
    '8',"tuv"},{
    
    '9',"wxyz"}};
    vector<string> ans; //保存形成完的字符串
    string rec; //暂存要压入数组的形成中的字符串
    void DFS(string digits, int index)
    {
    
    
        if(index == digits.size())
        {
    
    
            ans.emplace_back(rec);
            return;
        }
        string str=mp[digits[index]];
        for(char ch:str)
        {
    
    
            rec.push_back(ch);
            DFS(digits, index+1);
            rec.pop_back();
        }
    }
     //上两句已经对选择当前数的情况进行了记录,记录完成后自然要将rec变回原样。具体可以根据二叉树配合理解
    vector<string> letterCombinations(string digits) {
    
    
        //易错点:容易漏掉输入digits为“”空串的情况,
        //代码误输出为[""],正确结果应该为[]
        if(digits.empty())
        {
    
    
            return ans;
        }
        DFS(digits, 0);
        return ans;        
    }

易错点:
1)容易漏掉输入digits为“”空串的边界情况,代码误输出为[“”],正确结果应该输出[]
2)本题中哈希表第一个键值为char类型,每个键值对用花括号单独括出来
3)用vector<string> ans; 类型来保存结果字符串,注意不是vector<vector<string>> ans,这个是数组嵌套数组了

全排列

这一块一共两道全排列题目,难度递增。
46. 全排列
题目:给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

【思路】虽然大部分深搜都是用临时数组res来保存中间符合条件的结果值,但并不是所有深搜都是新建一个临时保存数组res,挨着填入元素,到头后再传入ans数组中。
普通无重复元素的全排列是通过交换调整原数组元素位置,动态调整原nums数组的排列顺序,每次到头后将nums数组传入ans数组中,从而避免了每次新建一个临时保存数组res。
具体调整nums数组排列顺序的方式为:从index下标开始,填入当前index位置及后面位置的任一个数。每次只与index及其右侧的元素交换,防止前面换过的,后面又被换一次。

	vector<vector<int>> ans;
    void DFS(vector<int>& nums, int index)
    {
    
    
    // 所有数都填完了
        if(index == nums.size())
        {
    
    
            ans.emplace_back(nums);//这里填入的是交换后的新nums组合
            return;
        }
        //从index开始,防止前面换过的,后面又被换一次
        for(int i=index; i<nums.size(); ++i)
        {
    
    
        //并不是所有深搜都是新建数组,挨着填入元素。
        //全排列是交换调整原数组元素位置,每次只与index右侧的元素交换
         // 动态维护数组,填入当前index位置及后面位置的任一个数
            swap(nums[i], nums[index]);
            // 继续递归填下一个数
            DFS(nums, index+1);
             // 撤销操作,换回当前数字,准备换下一个数字 
            swap(nums[i], nums[index]);
        }
    }
    vector<vector<int>> permute(vector<int>& nums) {
    
    
        DFS(nums, 0);
        return ans;
    }

【注意点】
1)普通无重复元素数组的全排列,通过swap数组中index元素和index及其右侧元素来调整nums原数组的排列顺序,无需新建临时保存数组res
2)注意for循环中i从index开始到末尾,防止前面交换过的元素后面又被交换一次。

47. 全排列 II
题目:给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
示例 1:
输入:nums = [1,1,2]
输出:[[1,1,2], [1,2,1], [2,1,1]]

【思路】对nums数组进行排序,将相同元素放在一起。然后设置一个vis[n]数组来记录元素是否访问,通过vis[n]数组来限制相同元素的访问顺序,固定同一种访问顺序,然后再穿插其他元素进行全排列。后面回溯出该元素时需要pop_back()并将vis[i]重置为0,还原回回溯前的样子。

	vector<vector<int>> ans;
    vector<int> res;
    vector<int> vis;
    void DFS(vector<int>& nums, int index)
    {
    
    
        if(index == nums.size())
        {
    
    
            ans.emplace_back(res);
            return ;
        }
        for(int i=0; i<nums.size(); ++i)
        {
    
    
         //限制相同元素的访问顺序,固定同一种访问顺序,然后再全排列
            if(vis[i] || (i>0 && vis[i-1]==0 && nums[i]==nums[i-1])) 
            //需要保证 i-1>=0
            {
    
    
                continue;
            }
            res.emplace_back(nums[i]);
            vis[i] = 1;
            DFS(nums, index+1);
            res.pop_back(); //pop_back()无参数
            vis[i] = 0;
        }
    }
    vector<vector<int>> permuteUnique(vector<int>& nums) {
    
    
        sort(nums.begin(), nums.end());
        vis.resize(nums.size());
        DFS(nums, 0);
        return ans;
    }

【注意点】
1)for循环中i从0开始
2)循环体中的if…continue判断中不访问当前元素有两种情况:当前元素下标i被访问过;当前元素与前一个元素相同,并且前一个元素没被访问过,这里需要格外注意,因为用到了i-1下标,因此还需要提前保证i>0
3)每次push_back压入res一个元素,就需要将该元素下标vis[i]置为1;
同样,回溯完之后还需要将该元素pop_back()出来,vis[i]置回0;
需要注意用的是pop_back();没有参数

子集与组合

这一块一共四道题,按照难易做题顺序推荐为:
78. 子集
39. 组合总和
90. 子集 II
40. 组合总和 II

下面是题目总结,注意与推荐做题顺序不一致:

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 。仅有这两种组合。

【思路】回溯,分为要当前元素与不要当前元素两种情况,当target减为零时存入结果。

【注意点】
1)边界条件,往往是深搜到容器末尾或者超出限制条件,或者满足限制条件。通常利用边界条件进行剪枝。
此题的边界条件有两个:
下标index == candidates.size() 或 target<0时找答案失败,直接return,进行回溯;
当target = 0时候,找到一个合规答案res,存入ans;

解答:

	vector<vector<int>> ans;
    vector<int> res;
    void DFS(vector<int>& candidates, int index, int target)
    {
    
    
     //边界条件,往往是深搜到容器末尾或者超出限制条件,或者满足限制条件。
     //利用边界条件进行剪枝
        if(index == candidates.size() || target<0)
        {
    
     
            return;
        }
        if(target == 0)
        {
    
    
            ans.emplace_back(res);
            return;
        }
        //不要当前数
        DFS(candidates, index+1, target);
        //要当前数
        //想运行更快的话,需要在这里判断if(target-candidates[index] >= 0)
        res.emplace_back(candidates[index]);
        DFS(candidates, index, target-candidates[index]);
        res.pop_back();//pop_back()括号中没数字        
    }
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
    
    
        DFS(candidates, 0, target);
        return ans;
    }

78. 子集
该题是 39. 组合总和的简单版本,没有target的限制,两个题可以放一起对比着学习。
【题目】给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

【思路】对于当前的第index个数,分为不要当前元素与要当前元素两种情况。要当前元素时**,不要忘记回溯时要pop_back()出当前添加的元素。**

vector<vector<int>> ans;
    vector<int> res;
    void DFS(vector<int>& nums, int index)
    {
    
    
        if(index == nums.size())
        {
    
    
            ans.emplace_back(res);
            return;
        }
        //不要当前元素
        DFS(nums, index+1);
        //要当前元素
        res.emplace_back(nums[index]);
        DFS(nums, index+1);
        res.pop_back(); //容易忘记回溯时pop出已添加的元素
    }
    vector<vector<int>> subsets(vector<int>& nums) {
    
    
        DFS(nums, 0);
        return ans;
    }

40. 组合总和 II
给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用 一次
注意:解集不能包含重复的组合

示例 1:
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出: [[1,1,6], [1,2,5], [1,7], [2,6]]

【思路】对于当前的第index个数,我们有两种方法:选或者不选。
在求出组合的过程中需要进行去重的操作。我们可以考虑将相同的数放在一起进行处理,使用一个哈希映射(HashMap)pair列表 freq,来统计数组 candidates 中每个数出现的次数,从而代替原始的candidates数组。每次选当前元素时,尝试选不同数量的当前元素,对应的target也需要减去不同数量的当前元素之和

优化(剪枝):将 freq 根据数从小到大排序,这样我们在递归时会先选择小的数,再选择大的数
这样做的好处是,当我们递归到DFS(index,rest) 时,如果 freq[index][0] 已经大于target,那么后面还没有递归到的数也都大于target,这就说明不可能再选择若干个和为target的数放入列表了。此时,我们就可以直接回溯。

【注意点】
1)先判断target是否满足=0的条件,因为一旦target==0,下面那个if剪枝语句也一定会满足return条件
2)每次尝试选不同数量的当前元素时,先确定好可以选择的最大数量。选了当前元素i次,则需要用target减去i倍的当前元素,注意i要从1开始
3)元素进出res,要分开for循环,进行回溯
4)vector<pair<int,int>> freq;哈希数组列表的建立过程也需要好好学习。

	vector<vector<int>> ans;
    vector<int> res;
    vector<pair<int,int>> freq; //将相同元素合并在一起,遍历这个数组,来替代遍历candidate数组。哈希表无序不太好用
    void DFS(int target, int index)
    {
    
    
        //必须先判断target是否满足条件,因为一旦target==0,下面那个if语句也满足return条件
        if(target == 0)
        {
    
    
            ans.emplace_back(res);
            return;
        }
        //优化剪枝:因为freq从小到大排列的,当前元素都不满足大小要求的话,后面的元素更不必试
        if(index == freq.size() || target < freq[index].first)
        {
    
    
            return;
        }
        //当前元素只有两种分支选择:选或不选
        //不选当前元素及相同元素
        DFS(target, index+1);
        //选当前元素,尝试选不同数量的当前元素
        int maxnum = min(target/freq[index].first, freq[index].second);//可以选择当前相同元素的最大数量
        for(int i =1; i<=maxnum; ++i)
        {
    
    
            res.emplace_back(freq[index].first);
            DFS(target-i*freq[index].first, index+1); //选了这个数i次,需要乘i,注意i要从1开始
        }
        //注意这里元素进出res,要分开for循环
        for(int i =0; i<maxnum; ++i)
        {
    
    
            res.pop_back();//回溯回到当前节点
        }
    }
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
    
    
        sort(candidates.begin(),candidates.end());//排序,方便剪枝;也方便从小到大存入哈希数组列表中
        //哈希数组列表的建立过程也需要好好看
        for(int a:candidates)
        {
    
    
            if(freq.empty() || freq.back().first != a)
            {
    
    
                freq.emplace_back(make_pair(a, 1));
            }
            else
            {
    
    
                freq.back().second++;
            }
        }
        DFS(target, 0);
        return ans;
    }

90. 子集 II
该题算是40. 组合总和 II的简单版本,没有target的限制,两个题可以对比着一起练习。
【题目】给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。

示例 1:
输入:nums = [1,2,2]
输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]

【思路】用freq哈希数组按照从小到大的顺序依次记录nums原数组中每个元素及其出现的次数,之后便可以只对freq数组进行操作。
对于当前index元素分为要与不要两种情况。
要当前元素时,要分别用两个for循环来emplace_back当前元素 和 pop_back(),因为不分开的话就会进一个当前元素则出一个当前元素,始终不能同时重复进多个当前元素;并且子集还会重复freq[index].second次

	vector<vector<int>> ans;
    vector<int> res;
    vector<pair<int,int>> freq;
    void DFS(int index)
    {
    
    
        if(index == freq.size())
        {
    
    
            ans.emplace_back(res);
            return;
        }
        DFS(index+1);
        for(int i=0; i<freq[index].second; ++i)
        {
    
    
            res.emplace_back(freq[index].first);
            DFS(index+1);
            //res.pop_back();
        }
        //要分开for循环来pop,因为不分开的话就会进一个当前重复元素则出一个当前重复元素,
        //始终不能同时进多个当前重复元素。
        //并且子集还会重复freq[index].second次
        for(int i=0; i<freq[index].second; ++i)
        {
    
    
            res.pop_back();
        }
    }
    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
    
    
        sort(nums.begin(), nums.end());
        for(int a : nums)
        {
    
    
            if(freq.empty() || freq.back().first != a)
            {
    
    
                freq.emplace_back(a,1);
            }
            else
            {
    
    
                freq.back().second++;
            }
        }
        DFS(0);
        return ans;
    }

2044. 统计按位或能得到最大值的子集数目
给你一个整数数组 nums ,请你找出 nums 子集 按位或 可能得到的 最大值 ,并返回按位或能得到最大值的 不同非空子集的数目 。
如果数组 a 可以由数组 b 删除一些元素(或不删除)得到,则认为数组 a 是数组 b 的一个 子集 。如果选中的元素下标位置不一样,则认为两个子集 不同 。
对数组 a 执行按位或 ,结果等于 a[0] | a[1] |… | a[a.length - 1](下标从 0 开始)。

示例 1:
输入:nums = [3,1]
输出:2
解释:子集按位或能得到的最大值是 3 。有 2 个子集按位或可以得到 3 : [3]、 [3,1]

示例 2:
输入:nums = [2,2,2]
输出:7
解释:[2,2,2] 的所有非空子集的按位或都可以得到 2 。总共有   2 3 − 1 = 7 \ 2^3-1=7  231=7个子集。

【分析】
参数 pos 表示当前下标, Valsum 表示当前下标之前的某个子集按位或值,这样就可以保存子集按位或的值的信息,并根据当前元素选择与否更新 Valsum。当搜索到最后位置时,更新最大值和子集个数。

	int maxval = 0;
    int cunt = 0;
    void DFS(vector<int>& nums, int pos, int valsum)
{
    
    
//搜索到最后位置了
        if(pos == nums.size())
        {
    
               
            if(valsum > maxval)
            {
    
    
                maxval = valsum;
                cunt = 1;
            }
            else if(valsum == maxval)
            {
    
    
                cunt++;
            }
            return ;//漏了
        }
        DFS(nums, pos+1, valsum|nums[pos]);
        DFS(nums, pos+1, valsum);
    }
    int countMaxOrSubsets(vector<int>& nums) {
    
    
        DFS(nums, 0, 0);
        return cunt;        
    }
法二
       //遍历(2^n−1) 种情况的方法:
  	   //将1左移n位,然后赋值给32位的statenumber,int型其最高位为符号位
        int n = nums.size() , stateNumber = 1 << n;
        for (int i = 0; i < stateNumber; i++) {
    
    
               
        //判断i的第j位是0还是1:
		if (((i >> j) & 1) == 1)if ((i & (1 << j)) != 0)

运算表达式

241. 为运算表达式设计优先级
给你一个由数字和运算符组成的字符串 expression ,按不同优先级组合数字和运算符,计算并返回所有可能组合的结果。你可以 按任意顺序 返回答案。
生成的测试用例满足其对应输出值符合 32 位整数范围,不同结果的数量不超过 104 。

示例 1:
输入:expression = “2-1-1”
输出:[0,2]
解释: ((2-1)-1) = 0 (2-(1-1)) = 2

【分析】记忆化搜索
我们首先对 expression \textit{expression} expression 做一个预处理,把全部的操作数(包括数字和算符)都放到 ops \textit{ops} ops 数组中,因为题目数据满足每一个数字都是 [ 0 , 99 ] [0,99] [0,99] 的范围中,且算符总共有 3 个,所以我们分别用 − 1 , − 2 , − 3 -1,-2,-3 123 来表示算符 + , − , ∗ +,−,* +。因为对于表达式中的某一个算符 op \textit{op} op,我们将其左部可能的计算结果用 left \textit{left} left 集合来表示,其右部可能的计算结果用 right \textit{right} right 集合来表示。那么以该算符为该表达式的最后一步操作的情况的全部可能结果就是对应集合 left \textit{left} left 和集合 right \textit{right} right 中元素对应该算符操作的组合数。那么我们枚举表达式中的全部算符来作为 left \textit{left} left right \textit{right} right 的分隔符来求得对应的集合,那么该表达式最终的可能结果就是这些集合的并集。

为了避免相同区间的重复计算,我们 dp [ l ] [ r ] = { v 0 , v 1 , … } \textit{dp}[l][r] = \{v_0,v_1,\ldots\} dp[l][r]={ v0,v1,} 来表示对应表达式 ops [ l : r ] \textit{ops}[l:r] ops[l:r] 在按不同优先级组合数字和运算符的操作下能产生的全部可能结果。这样我们就可以通过记忆化搜索这种「自顶向下」的方式来进行求解原始的表达式的全部可能计算结果。而上面讨论的条件是需要计算的表达式中存在算符的情况,所以还需要讨论搜索结束的条件:当表达式不存在任何算符时,即 l = r l = r l=r 时,对应的结果集合中就只有该一个数字。

dp [ l ] [ r ] = { ops [ l ] } , l = r & ops [ l ] ≥ 0 \textit{dp}[l][r] = \{\textit{ops}[l]\} , l = r \And \textit{ops}[l] \ge 0 dp[l][r]={ ops[l]},l=r&ops[l]0

class Solution {
    
    
    const int add = -1;
    const int sub = -2;
    const int mul = -3;
public:
    //dp[l][r]表示对应表达式 ops[l:r] 在按不同优先级组合数字和运算符的操作下能产生的全部可能结果
    //计算dp[l][r]的全部可能结果,用一维数组记录
    vector<int> dfs(vector<vector<vector<int>>>& dp, int l, int r, const vector<int>& ops){
    
    
        //dp[l][r]很可能重复分治,因此需要先判断是否已经计算过
        if(dp[l][r].empty()){
    
    
            if(l == r){
    
    
            dp[l][r].emplace_back(ops[l]);
            }else{
    
    
                //i每次 +2,越过操作符(i + 1)
                //进行分治
                for(int i = l; i < r; i += 2){
    
    
                auto left = dfs(dp, l, i, ops);
                auto right = dfs(dp, i + 2, r, ops);
                for(auto &u : left){
    
    
                    for(auto &v : right){
    
    
                        if(ops[i + 1] == add){
    
    
                            dp[l][r].emplace_back(u + v);
                        }else if(ops[i + 1] == sub){
    
    
                            dp[l][r].emplace_back(u - v);
                        }else{
    
    
                            dp[l][r].emplace_back(u * v);
                        }
                    }
                }
            } 
            }
        }
        return dp[l][r];        
    }
    vector<int> diffWaysToCompute(string expression) {
    
    
        vector<int> ops;
        int n = expression.size();
        //用ops数组保存所有的操作数与运算符
        //注意下面的i没有++
        for(int i = 0; i < n;){
    
    
            if(!isdigit(expression[i])){
    
    
                if(expression[i] == '+'){
    
    
                    ops.emplace_back(add);
                }else if(expression[i] == '-'){
    
    
                    ops.emplace_back(sub);
                }else{
    
    
                    ops.emplace_back(mul);
                }
                ++i;
            }else{
    
    
                int res = 0;
                //因为i一直 ++,因此容易忽略边界条件:i < n 
                //用while循环可以将两位数字作为一个操作数整体添加到ops数组中
                while(i < n && isdigit(expression[i])){
    
    
                    res = res * 10 + expression[i] - '0';
                    ++i;
                }
                ops.emplace_back(res);
            }
        }
        //三维数组,每个dp[l][r]中都保存一个一维数组
        vector<vector<vector<int>>> dp(ops.size(), vector<vector<int>>(ops.size()));
        return dfs(dp, 0, ops.size() - 1, ops);
    }

单词搜索

79. 单词搜索
【题目】给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
示例1:

输入:board = [[“A”,“B”,“C”,“E”],[“S”,“F”,“C”,“S”],[“A”,“D”,“E”,“E”]], word = “ABCCED”
输出:true

【分析】这是一道比较难的回溯,边界条件是搜索到单词末尾返回true,以及当前字母不匹配时候直接返回false。
需要建立一个与网格同等大小的vis数组,类型为int,用于记录以某个[i,j]起始位置开始搜索单词时,某个位置是否被访问过,防止重复访问之前搜过的位置,记得需要在搜完[i,j]起始位置情况后,将vis[i][j]重置为0(回溯)。
若当前位置字母匹配,则深度搜索下一个相邻位置(上下左右),这里有个技巧:
可以设置

vector<pair<int,int>> direction = {
    
    {
    
    0,1},{
    
    0,-1},{
    
    1,0},{
    
    -1,0}};,通过for(auto pa:direction)
        {
    
      
            int newi = i + pa.first;
            int newj = j + pa.second; 

来遍历上下左右四个相邻位置。
【注意】
1)遍历相邻位置时候需要判断位置是否越界
2)深度搜索之前需要判断当前位置是否已经访问过,否则就重复使用同一单元格字母了。

bool DFS(vector<vector<char>>& board, string word, vector<vector<bool>>& vis, int i, int j, int k)
    {
    
    
        if(board[i][j]!=word[k])
        {
    
    
            return false;
        }
        else if(k == word.size()-1)
        {
    
    
            return true;
        }
        vis[i][j] = true;
        vector<pair<int,int>> direction = {
    
    {
    
    0,1},{
    
    0,-1},{
    
    1,0},{
    
    -1,0}};
        for(auto pa:direction)
        {
    
      
            int newi = i + pa.first;
            int newj = j + pa.second;         
            //需要判断边界不能越界
            if(newi>=0 && newi<board.size() && newj>=0 && newj<board[0].size())
            {
    
    
                if(!vis[newi][newj]) //还需要判断以【i,j】开始搜寻后,不能重复返回访问
                {
    
    
                    if(DFS(board, word, vis, newi, newj, k+1))
                    {
    
    
                        return true;
                    }
                }
            }
        }
        vis[i][j] = false;//最后需要回溯回来
        return false;
    }
    bool exist(vector<vector<char>>& board, string word) {
    
    
        int m = board.size();
        int n = board[0].size();
        vector<vector<bool>> vis(m, vector<bool>(n));
        for(int i=0; i<m; ++i)
        {
    
    
            for(int j=0; j<n; ++j)
            {
    
    
                if(DFS(board, word, vis, i, j, 0))//遍历每一个位置
                {
    
    
                    return true;
                }
            }
        } 
        return false;
    }

【特别推荐–精简答案版本】推荐背过

bool exist(vector<vector<char>>& board, string word) {
    
    
        int m = board.size();
        int n = board[0].size();
        for(int i=0; i<m; ++i)
        {
    
    
            for(int j=0; j<n; ++j)
            {
    
    
                if(dfs(board, word, i, j, 0))
                {
    
    
                    return true;
                }
            }
        }
        return false;
    }
    bool dfs(vector<vector<char>>& board, string word, int i, int j, int k)
    {
    
    
        if(i<0 || i>=board.size() || j<0 || j>=board[0].size() || board[i][j] != word[k]) return false;
        if(k == word.size()-1) return true;
        board[i][j] = '/0';
        bool res = dfs(board, word, i+1, j, k+1) || dfs(board, word, i-1, j, k+1) || 
        dfs(board, word, i, j+1, k+1) || dfs(board, word, i, j-1, k+1);
        board[i][j] = word[k];
        return res; //注意return的是res
    }

473. 火柴拼正方形
你将得到一个整数数组 matchsticks ,其中 matchsticks[i] 是第 i 个火柴棒的长度。你要用 所有的火柴棍 拼成一个正方形。你 不能折断 任何一根火柴棒,但你可以把它们连在一起,而且每根火柴棒必须 使用一次 。

如果你能使这个正方形,则返回 true ,否则返回 false 。

示例 1:

输入: matchsticks = [1,1,2,2,2]
输出: true
解释: 能拼成一个边长为2的正方形,每边两根火柴。

示例 2:
输入: matchsticks = [3,3,3,3,4]
输出: false
解释: 不能用所有火柴拼成一个正方形。

【分析】
回溯
首先计算所有火柴的总长度 totalLen \textit{totalLen} totalLen,如果 totalLen \textit{totalLen} totalLen 不是 4 的倍数,那么不可能拼成正方形,返回 false \text{false} false。当 totalLen \textit{totalLen} totalLen 是 4 的倍数时,每条边的边长为 len = totalLen 4 \textit{len} = \dfrac{\textit{totalLen}}{4} len=4totalLen,用 edges \textit{edges} edges 来记录 4 条边已经放入的火柴总长度。对于第 index \textit{index} index 火柴,尝试把它放入其中一条边内且满足放入后该边的火柴总长度不超过 len \textit{len} len,然后继续枚举第 index + 1 \textit{index} + 1 index+1 根火柴的放置情况,如果所有火柴都已经被放置,那么说明可以拼成正方形。
为了减少搜索量,需要对火柴长度从大到小进行排序。

	//vector<int> edges(4) 编译器会无法区分这个vector是成员变量声明还是成员方法声明
    vector<int> edges = vector<int>(4);
    bool dfs(vector<int>& matchsticks, int index, int target){
    
    
        //遍历了所有火柴,因为每根火柴都加到了某条边,并且所有边长度都<=target,因此每条边长度都等于target
        if(index == matchsticks.size()){
    
    
            return true;
        }
        for(int i = 0; i < 4; ++i){
    
    
            edges[i] += matchsticks[index];
            //如果找到一个可行解就返回true,否则就说明当前火柴不能加到该边上
            if(edges[i] <= target && dfs(matchsticks, index + 1, target)){
    
    
                return true;
            }
            //当前火柴不能加到该边上,回溯,并++i,加到下一条边上
            edges[i] -= matchsticks[index];
        }
        //如果当前火柴加不到所有的四条边上(加在哪条边上都超长了,说明前面有的火柴拼接组合不对),
        //就返回false,以便于进行if语句下面的回溯,将回溯回的火柴加到下一条边上
        //如果回溯到最开始的火柴,该火柴加到4条边上都没找到可行解,则最终得到一个false
        return false;
    }
    bool makesquare(vector<int>& matchsticks) {
    
    
        int n = matchsticks.size();
        int sumlen = accumulate(matchsticks.begin(), matchsticks.end(), 0);
        if(sumlen % 4 != 0) return false;
        //降序排序!!!技巧,否则会超时
        sort(matchsticks.begin(), matchsticks.end(), greater<int>());      
        return dfs(matchsticks, 0, sumlen / 4);        
    }

岛屿问题

通用解法:链接
695. 岛屿的最大面积
1020. 飞地的数量
200. 岛屿数量
给你一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。

示例 1:
输入:grid = [
[“1”,“1”,“1”,“1”,“0”],
[“1”,“1”,“0”,“1”,“0”],
[“1”,“1”,“0”,“0”,“0”],
[“0”,“0”,“0”,“0”,“0”]
]
输出:1

深度优先搜索:
将二维网格看成一个无向图,竖直或水平相邻的 1 之间有边相连。
为了求出岛屿的数量,我们可以扫描整个二维网格。如果一个位置为 1,则以其为起始节点开始进行深度优先搜索。在深度优先搜索的过程中,每个搜索到的 1 都会被重新标记为 0。
最终岛屿的数量就是我们进行深度优先搜索的次数。

	void dfs(vector<vector<char>>& grid, int i, int j){
    
    
        if(i < 0 || i >= grid.size() || j < 0 || j >= grid[0].size() || grid[i][j] == '0') return;
        if(grid[i][j] == '1'){
    
    
            grid[i][j] = '0';
            dfs(grid, i + 1, j);
            dfs(grid, i - 1, j);
            dfs(grid, i, j - 1);
            dfs(grid, i, j + 1);
        }
    }
    int numIslands(vector<vector<char>>& grid) {
    
    
       int m = grid.size();
       int n = grid[0].size();
       int ans = 0;
       for(int i = 0; i < m; ++i){
    
    
           for(int j = 0; j < n; ++j){
    
    
               if(grid[i][j] == '1'){
    
    
                   dfs(grid, i, j);
                   ++ans;
               }
           }
       }
    return ans;
    }

方法二:并查集
代码讲解

1254. 统计封闭岛屿的数目
130. 被围绕的区域

猜你喜欢

转载自blog.csdn.net/XiaoFengsen/article/details/123582371