作者:小 琛
欢迎转载,请标明出处
引言:动态规划是算法中较难的内容,常常被用来区分学习者的档次,笔者也是在面试中发现了这个问题,故而回头认真学习并总结递归算法,希望能够帮助到阅读的你
动态规划的思想
“世界上的所有问题,都可以化繁为简,化简为空”。这种思想本身就是一门艺术,就像写代码的我们,同样在做一门艺术。
1. 动归从某种意义讲其实就是递归的优化
以前我们接触过一种思想:递归。一个大问题可以拆分为若干个小问题,而小问题最后变为了一个确定的、简单的问题。动态规划的思路本身就和递归类似:当遇到一个问题时,这个问题的解决是有规律可循的,可将该问题拆分为多个小问题来解决,例如斐波那契数列中,F(n)的求解等于F(n-1)+F(n-2)。
而当我们确定了这些规律后,动态规划所做的操作,就是将这类规律总结,形成一个动态转移方程,例如上面的斐波那契数列的F(n)=F(n-1)+F(n-2)本质就是一个动态转移方程。
2. 动归对于递归最大的优化就是用记录法代替栈帧的开销
C++学习者肯定明白,递归的过程就是函数栈帧的开辟,当递归次数增大而导致程序崩溃时,就是因为过多的栈帧导致的栈溢出。而动态规划常用的办法,定义数组dp(该数组需要根据需求设定,可能是一维,也可能二维),dp用来记录每一个状态,从而实现求解下一个状态的时候直接从之前的状态获得即可。
动态规划的具体实现步骤
- 确定问题。F(n)是一个什么样的求解
- 如果题目不好分析,可试写几个例子,帮助分析
- 将原问题拆分
- 确定动态转移方程
- 确定初始状态
- 使用记录法(数组dp)来记录每一个状态
入门级别的两个例子
斐波那契数列
写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项(即 F(N))。斐波那契数列的定义如下:
F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
题目分析:斐波那契数列常用来学习循环或递归,此处我们用动态规划思路解决。
- 问题,求F(n)
- 写几个简单例子帮助分析
F(0)=0,F(1)=1,F(2)=2,F(3)=3,F(4)=5
- 拆分问题。F(n)的值为F(n-1)与F(n-2)的和
- 写转移方程:F(n)=F(n-1)+F(n-2)
- 确定初始状态,F(0)=0,F(1)=1
- 记录法,表示动归的每一步
class Solution {
public:
int fib(int n) {
if (n==0)
return 0;
if (n==1)
return 1;
vector<int> dp(n+1,0);
dp[1]=1;
for (int i=2;i<n+1;i++)
{
dp[i]=(dp[i-1]+dp[i-2])%1000000007;
}
return dp[n];
}
};
变态跳台阶问题
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶,也可以跳n级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
解题思路:这道题其实是一道经典的递归问题,而此处我们用动态规划来解答。仍然按照常规步骤:
- 问题:求F(n)级台阶的跳法。
- 写几个简单的例子帮助分析。
n=1,F(n)=1;
n=2,F(n)=2;
n=3,F(n)=4;
n=4,F(n)=8;
- 拆分成子问题:F(n)级台阶的跳法=F(n-1)+F(n-2)+F(n-3)+…F(1)+1
- 写转移方程。在动归中,转移方程需要满足的要求是:达到可确定公式,因此我们需要将上面的公式进一步化简,而F(n-1)=F(n-2)+F(n-3)+F(1)+1,因此F(n)=2F(n-1)
- 确定初时状态:dp[0]=1,dp[1]=1
- 采用记录法,来表示每一步动归
#include <iostream>
#include <vector>
class Solution {
public:
int numWays(int n)
{
vector<int> dp(n+1,0);
dp[0]=1;
for (int i=1;i<=n;i++)
{
dp[i]=2*dp[i-1];
}
return dp[n];
}
};
动归的难点,当分析复杂化
经典题目1、string break
前面谈到,动态规划的核心,在于问题的确定、转移方程的列写、初始值的确定、记录数组的定义。当分析的问题难度提升,则前三步有难度时,动态规划题目级别就上升了,此时则需要认真分析,核心点还是在于抓住动态规划的解决步骤,将困难的问题简单化
题目:
给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。
说明:
拆分时可以重复使用字典中的单词。
你可以假设字典中没有重复的单词。
示例 1:
输入: s = “leetcode”, wordDict = [“leet”, “code”]
输出: true
解释: 返回 true 因为 “leetcode” 可以被拆分成 “leet code”。
题目解答
- 确定问题。判断s中的若干部分是否可以分割成wordDict中的单词
- 举例帮助分析:le不可以;leet可以;leetc中leet可以但c不可以,因此不可以…
- 拆分问题:判断一个字符串是否可以分割,即针对每个字符处,从上一个满足要求的字符开始,到该字符处的这部分字符,是否满足分割要求,例如判断e处,则需要判断le是否满足;t处,需要判断leet;o处,可以是leetco亦可以从c开始co
- 转移方程:判断当前处是否满足分割,要看上一个满足分割要求的字符处开始到该处是否满足词典,如果满足则该字符处满足,否则该处不满足
即:dp[i]=dp[j]+check(s[j]…s[i-1]) 其中i为当前下标,j为上一个满足的下标
- 确定初始状态:若字符为第0个,则一定满足
- 用记录法确定每一步动归
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> myset;
for (auto str: wordDict)
{
myset.insert(str);
}
vector<bool> dp(s.size()+1);
dp[0]=true;
for (int i=1;i<dp.size()+1;i++)
{
for(int j=0;j<i;j++)
{
if (dp[j] && myset.find(s.substr(j,i-j))!=myset.end())
{
dp[i]=true;
break;
}
}
}
return dp[s.size()];
}
总结:该题目的难点在于最初的分析,很多时候无法和动态规划连接在一起,因此在遇到这类题目的时候,可以多举几个例子,查看分析当前的判断和之前的判断有何种联系,得到这种递推式的关系后,再列写状态转移方程。同时需要将之前学习过的知识联系起来,方可解答。
经典题目2、三角形最小路径和
j
题目分析:
该题是一道经典的动归题目。分析采用常规流程即可
- 问题:求小路径和
- 举例帮助理解:2-3-5-1
- 拆分问题,某一个位置的路径和为上一层所在点所能走的两个点的最小值,但唯一最左边和最右边需要特殊处理
- 动态转移方程:F(x,y)=min(F(x-1,y-1),F(x,y-1))+triangle[x,y]
- 初始状态:对于[0,0]点,路径为该点本身的值
- 用数组记录方式统计每一个状态
int minimumTotal(vector<vector<int>>& triangle) {
int n=triangle.size();
vector<vector<int>> dp(n,vector<int>(n));
dp[0][0]=triangle[0][0];
int i,j;
for ( i=1;i<n;i++)
{
dp[i][0]=dp[i-1][0]+triangle[i][0];
for ( j=1;j<i;j++)
{
dp[i][j]=min(dp[i-1][j-1],dp[i-1][j])+triangle[i][j];
}
dp[i][i]=dp[i-1][j-1]+triangle[i][j];
}
return *min_element(dp[n-1].begin(),dp[n-1].end());
}