动态规划类
学习笔记来自公众号labuladong
- 动态规划的一般形式就是求最值——其核心问题是穷举
- 但动态规划的穷举有些特别,因为这类问题存在重叠子问题 如果暴力穷举的话效率会极其低下,所以需要**「备忘录」或者「DP table」**来优化穷举过程,避免不必要的计算
- 动态规划问题一定具备最优子结构,才能通过子问题的最值得到原问题的最值,要符合“最优子结构”,子问题间必须互相独立。
- 只有正确列出状态转移方程才能正确地穷举
- 明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case。
- 重叠子问题可以理解为重复计算的部分,解决重叠子问题可以通过备忘录和dptable:
①备忘录:
“明确了问题,其实就已经把问题解决了一半。即然耗时的原因是重复计算,那么我们可以造一个「备忘录」”
每次算出某个子问题答案后别急着返回,先记到备忘录中再返回;每次遇到一个子问题先去备忘录查一查,如果之前已经解决过了就直接拿来用,不要重复去计算。
一般使用数组或者哈希表充当备忘录
但带备忘录的递归解法叫做自顶向上,即从上向下延伸,一个规模较大的问题向下逐渐分解规模,直到触底,再逐层返回答案呢
而自底向上,是直接从最底下,最简单,问题规模最小开始往上推,直到推到想要的答案,一般动态规划都是自底向上,“这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算”
② dp数组的迭代解法:在这张表上完成自底向上的推算
正则表达式匹配
6/100正则表达式匹配
给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 ‘.’ 和 ‘*’ 的正则表达式匹配。
'.' 匹配任意单个字符
'*' 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。
根据通配符进行大致分类
if(s[i] == p[j] || p[j] == '.')
{
if(j<p.size()-1 && p[j+1] == '*')
{
//有* 可匹配0次或者多次
}else{
//无* 都+1再进行比较
i++; j++;
}
}else
{
//如果有*
if(j<p.size()-1 && p[j+1] == '*')
{
//匹配0次
}else{
//无* 肯定不行了
return false;
}
}
接下来就是在有*的情况下,把所有的可能选择都穷举一遍得到结果
即状态和选择,状态就是i和j的位置,选择就是p[j]选择匹配几个字符
dp(s, i, p, j)=true,表示s[i…]可以匹配p[j…], 根据这个定义我们要的答案就是i= 0, j = 0时的答案,
if(s[i] == p[j] || p[j] == '.')
{
if(j<p.size()-1 && p[j+1] == '*')
{
//有* 可匹配0次或者多次
return dp(s, i ,p, j+2) || dp(s, i+1, j, p);
}else{
//无* 都+1再进行比较
i++; j++;
return dp(s, i+1, p, j+1);
}
}else
{
//如果有*
if(j<p.size()-1 && p[j+1] == '*')
{
//匹配0次
return dp(s, i, p, j+2);
}else{
//无* 肯定不行了
return false;
}
}
再考虑dp的base case 当j走到最后时,i有没有到最后; 到i走到最后时,p剩下的能不能匹配空字符串
注意当能匹配空字符串时,剩下的字符串一定会是偶数个? 而且一定是xyz*这样的形势,即第二个得是*
得到第一题动态规划的题解:
class Solution {
public:
bool dp(string s,int i, string p, int j) {
map<string, bool> memo;
//1先写base case
int m = s.size(), n = p.size();
if(j == n)
{
return i == m;
}
if(i == m)
{
//是否能匹配空字符串
//一定得是偶数个 且是以x*y*z*的形势
if((n-j)%2 == 1)
{
return false;
}
for(; j+1<n; j+=2)
{
if(p[j+1] != '*')
{
return false;
}
}
return true;
}
//2 写状态转移方程
//用备忘录
string key = to_string(i)+"_"+to_string(j);
if(memo.count(key)) return memo[key];
bool res = false;
if(s[i] == p[j] || p[j] == '.')
{
if(j<n-1 && p[j+1] == '*')
{
res = dp(s, i, p, j+2)|| dp(s, i+1, p,j);
}else{
res = dp(s, i+1, p, j+1);
}
}else{
if(j<n-1 && p[j+1] == '*')
{
res = dp(s, i, p, j+2);
}else{
res = false;
}
}
memo[key] = res;
return res;
}
bool isMatch(string s, string p) {
int i = 0, j=0;
return dp(s, i, p, j);
}
};
不过按此方法超出内存限制,打算不再耽搁,往下继续,遇到新的题目再复习再理解
回溯
首先,什么时候用到回溯算法?
回溯算法通常在需要解决组合优化问题、排列组合问题、图论问题、搜索问题等情况下被使用
具体来说就是当需要在一个问题的解空间中搜索所有可能的解,并且需要找到满足特定条件的解时,就可以考虑。
解决一个回溯问题,就是一个决策树的遍历过程。(各种搜索问题都是树的遍历问题)
考虑3个问题:
1 路径:已经做出的选择
2 选择列表:当前可以做的选择
3 结束条件:到达决策树底层,无法再做选择的条件
result = []
def backtrack(路径、选择列表)
if 满足结束条件
result.add(路径)
return
for 选择 in 选择列表
做选择
backtrack(路径,选择列表)
撤销选择
核心就是for循环里的递归,在递归调用之前做选择,在递归调用之后撤销选择。
for 选择 in 选择列表
#做选择
将该选择从选择列表移除
路径.add(选择)
backtrack(路径,选择列表)
#撤销选择
路径.remove(选择)
再将选择加入选择列表
全排列:
class Solution {
public:
void backtrack(vector<vector<int>> &res,vector<int>& nums, vector<int> tarck)
{
//结束条件
if(tarck.size() == nums.size())
{
res.push_back(tarck);//一个进去
}
//回溯的套路 遍历选择列表
for(int i = 0; i<nums.size(); i++)
{
if(std::find(tarck.begin(), tarck.end(), nums[i]) != tarck.end()) //已经存在 跳过
continue;
//加入
tarck.push_back(nums[i]);
//进入下一层循环树
backtrack(res, nums, tarck);
tarck.pop_back();
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<int> tarck;//记录的路径
vector<vector<int>> res;//保存的路径
backtrack(res, nums, tarck);//nums选择的路径
return res;
}
};
递归
但凡用到递归的问题,最好都画出递归树,这对分析算法的复杂度,需按照算法是否低效以及低效的原因都有帮助
递归的算法时间复杂度计算:子问题个数乘以解决一个子问题需要的时间