目录
(一)动态规划的简介及解题思路
1.什么是动态规划?
DP定义:
动态规划是分治思想的延伸,通俗一点来说就是大事化小,小事化无的艺术。
在将大问题化解为小问题的分治过程中,保存对这些小问题已经处理好的结果,并供后面处理更大规模的问题时直接使用这些结果。
动态规划具备了以下三个特点
1. 把原来的问题分解成了几个相似的子问题。
2. 所有的子问题都只需要解决一次。
3. 储存子问题的解。
2.动态规划的一般思路
动态规划的本质,是对问题状态的定义和状态转移方程的定义(状态以及状态之间的递推关系)
动态规划问题一般从以下四个角度考虑:
1. 状态定义——目前的定义一个中间状态下,需要考虑的问题
2. 状态间的转移方程定义 ——在上一个状态一步转移到这个状态
3. 状态的初始化——在程序开始的阶段,我们需要怎么样的设定,来进入这个转移方程
4. 返回结果——返回最终的结果
状态定义的要求:定义的状态一定要形成递推关系。
一句话概括:三特点四要素两本质
适用场景:最大值/最小值, 可不可行, 是不是,方案个数。
可能你看到这里因为过于抽象,理解有些难度,大概总结一下,首先我们要定义一个F(i)(或者一元状态无法描述时,定义二元F(i,j)),在这里假设我们已经知道了上一步的结果,我们在这里想如何让上一步进行某些操作来转化到这一步,通过这个转化关系,我们定义出状态转移方程。
这个方程需要适应题目的规律,什么意思呢?就是在任意一个状态下(代入任意i值),都可以用这个状态转移方程来得到答案。而如何来写状态转移方程,这就是一个难点了,就是随机找到一个过程,上个过程的结果F(i-1)已知,至于上个过程如何得到的结果,这里我们不关心(有点像贪心算法)然后再进行一定的验证,没有问题之后,来给这个公式的开始状态带入初始的、确切的数据(初始化状态),使它根据初始值 和利用状态转移方程把后面的数据求出来,并返回最后的一项(返回值),即答案。
总结一下:(1)根据题目描述获取问题 F(n),并先将 F(n)拆解成小部分,如下图所受
F(2):定义状态转移方程,保证在这个方程中,任意相邻的两个状态,从左到右,这个方程都适用。
补充理解:
关于上面所提到的“只关心这一步的值”我们怎么理解呢?其实就是根据状态转移方程,在前面i-1个过程中所得到当前结果已经是i-1个的过程已经求得,我们只要关心如何利用前i-1个状态转移到第i个状态即可。
每个过程都这么考虑,最后的得到的F(n)就是最终的前n个(即全部)状态的结果 。
(二)动态规划的常见题型及方法总结
1.斐波那契数列(Fibonacci)
题目网址链接:
牛客链接:
经典类型问题,有许多种解法,也有不同的时间、空间复杂度。在这里,我们考虑使用动态规划的方法来分析这个问题。
在这里首先定义状态,所谓的状态就是你要对其中一步,得到该步骤答案的过程,在这里的状态是F(i)表示i的值是多少的一种状态,然后就根据斐波那契数列公式来定义它的转移方程,并按照题目种已经给出的F(0)F(1)来定义初始值。
根据上述分析 来进行 首先3步走:
状态:F(i):第i项的值是多少。
状态转移方程:F(i)=F(i-1)+F(i-2)
初始状态:F(0)=0 F(1)=0
返回值:F(n)
根据上述内容画出表格:
代码实现:
class Solution {
public:
int Fibonacci(int n) {
int* F=new int [n+1];
F[0]=0;
F[1]=1;
for(int i=2;i<=n;i++){
F[i]=F[i-1]+F[i-2];
}
return F[n];
}
};
附传统递归算法进行参考:(栈溢出)
class Solution{
public: int Fibonacci(int n){ // 初始值
if (n <= 0){ return 0; }
if (n == 1 || n == 2) { return 1; }// F(n)=F(n-1)+F(n-2)
return Fibonacci(n - 2) + Fibonacci(n - 1); } };
2.字符串分割(Word Break)
题目链接:
简要分析:
我们还是按照原本的方式,首先定义状态,在这里状态不好定义,我们可以尝试的去想一下这个过程步骤,首先肯定是从头开始切,先切第一个字母,然后看第一个字母是否作为一个单词在词典中找到,然后如果不是的话,那我们就继续往下切,看看前两个字母组成的单词是否可以在词典中找到。如果前面的单词在词典中找到的话,那我们就要分析从切点往后的字母组成的单词是否可以找到,如果这个同时也可以找到,说明整个单词就可以被切分。
这里我们总结以下刚才的分析,我们可以定义F(i)表示前i个字符是可以被切割的,假设”leet“ ”code”是词典中的两个有效单词,这里我们以leet code为例,leetcode是“leetcodeaaabbb”字符串中中的一小部分,如何知道“leetcode”(该字符串中的前8个字符)是可以切分的呢,满足两点,第一(1-4字符)“leet”可以切分有效单词,第二“code(5-8字符)”为有效单词。
假设不是leetcode为前8个字符,假设是任意8个字母变为该字符串中的前8个字符,那么怎么判断前8个字符是否有效呢?
总结为表达式为:
在这8中情况中,只要满足一种,说明前8个字符可以拆分,这个时候我们其实就可以想到了其整个过程,画图手写如下:
老样子,三步走:
代码实现如下:
class Solution{
public:
bool wordBreak(string s, unordered_set<string> &dict){
if (s.empty()){ return false; }
if (dict.empty()){ return false; }
vector<bool> can_break(s.size() + 1, false); // 初始化F(0) = true
can_break[0] = true;
for (int i = 1; i <= s.size(); i++){
for (int j = i - 1; j >= 0; j--){
// F(i): true{j <i && F(j) && substr[j+1,i]能在词典中找到} OR false
// 第j+1个字符的索引为j
if (can_break[j] && dict.find(s.substr(j, i - j)) != dict.end()){
can_break[i] = true;
break; }
}
}return can_break[s.size()]; }
};
3.三角矩阵(Triangle)
题目链接:
首先看到题目的时候,通过前两道题,我们应该掌握到了一定的规律,这里状态不难定义,就是F(i)为从顶到i当前的最小路径和,但是这个地方有一个问题,这是一个二维平面的数组,我们想要确定一个数不能以简单的i一个值来判定了,所以这里我们就引进了二维状态。
问: 什么是二维状态?
答:由 i 和 j 两个变量描述的状态。
问: 什么时候用二维状态?
答:当一个变元已经没有办法确定地描述当前状态地时候,我们就引进两个变量。
这样,用 i j 表示 i 行 j 列的元素就可以了,好了,现在难点只有一个了,就是我怎么保证到第i行j列时,是从上一个i-1 ,j-1 取最小路径过来的呢?
我们可以观察一下,首先三角形边上的数据,到达他们的路径只有一条——沿着边走。
那么到达他们的只可能是F(i-1,j)(开头数据)或者F(i-1,j-1)
而对于非边的数据, 只能是F(i-1,j-1)和F(i-1,j)两者当中的最小值再加上当前值。例如:
图示:
代码如下:
class Solution { public: int minimumTotal(vector<vector<int>> &triangle) {
if (triangle.empty()){ return 0; }// F[i][j], F[0][0]初始化
vector<vector<int>> min_sum(triangle);
int line = triangle.size();
for (int i = 1; i < line; i++){
for (int j = 0; j <= i; j++){ // 处理左边界和右边界
if (j == 0){
min_sum[i][j] = min_sum[i - 1][j];
}
else if (j == i){
min_sum[i][j] = min_sum[i - 1][j - 1];
}
else{
min_sum[i][j] = min(min_sum[i - 1][j], min_sum[i - 1][j - 1]);
}// F(i,j) = min( F(i-1, j-1), F(i-1, j)) + triangle[i][j]
min_sum[i][j] = min_sum[i][j] + triangle[i][j];
}
}int result = min_sum[line - 1][0];
// min(F(n-1, i))
for (int i = 1; i < line; i++){
result = min(min_sum[line - 1][i], result);
}
return result; }
};