以前一直只是接触过动态规划相关的内容,但是从未系统学习过,所以这一次来做一个系统的总结。
一、概述
动态规划和递归,分治思想,贪心类似,但是又不同。
动态规划之所以特殊不同,是因为所有可以使用动态规划解决的问题都会涉及到两个特点:
1.具有重叠子问题;
2.具有最优子结构;
所谓重叠子问题,就是指在能够将整个大问题进行整体性分割之后,小问题可以互相联系和互相解决。这是和分治思想最不同的地方。对于分治算法来说,例如简单的分治排序,所有子序列互不相关,并且有独立性。但是动态规划中分割的子问题往往会出现重复,产生冗余计算。
例如书上的例子:对于一个斐波那契数列,往往的通用方法是通过递归来进行自上而下的计算处理,用递归方法来进行计算的情况下,会出现多次计算,例如F(4)=F(3)+F(2),F(5)=F(4)+F(3),可以看到F(4)被用来计算了两次。这显然是多余的计算,对于初始方法来说,计算复杂度会达到O(2^n)。如果用动态规划进行改进,使用标志数组来进行标记计算,则每一次F(n)只需要计算一次,通过查询标志数组的内容,而不去计算已经计算过的内容,时间复杂度可以到达线性程度。这个操作也就是记忆化搜索。
所谓最优子结构,个人认为可能多用在树状搜索的例子中。例如权值二叉树的最长路径问题,就可以用动态规划思想来解决。对于这个问题,先前进行二叉树构造学习的时候多用递推来进行解决,也就是遍历每一条可能路径,从而一直比较,得到最长路径。也就是二叉树的深度遍历,且构造方式多为自顶而下。
但是对于动态规划来说,又有不同。如果说递推是从上至下,则动态规划为从底至上。例如最长路径搜索路径的问题来说,可以理解为找某个节点当前的最长路径,从而可以构造递归公式:dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+f[i][j]),也成为状态转移方程。所以最后会从最底层的位置开始,逐层求出该节点下属的最长路径。
这里也要注意一下贪心算法的问题。贪心算法一定是单链的求解问题,也就是从根节点逐步来进行最大值求解,所以不会遍历到所有的可能路径,所以正确性还需要解决,具有盲目性和断然性。但是动态规划一定是全面且正确的。
二、最大连续子序列和:
动态规划的经典问题之一,能够完整的体现动态规划的思想。
大概题意:给定一个确定数字序列,要求从中找到一个子序列,使得序列内数字运算和最大。
例如:-2 11 -4 13 -5 -2
最大和为: 20
对于此类问题,通常解法大致为通过枚举左右端点来进行加和枚举,此方法计算复杂度较大。对于动态规划解法,时间复杂度仅有O(n)。
大致思想如下:
由于序列确定,所以可以将问题细分,设置一个dp数组,其中要求的最大值就为dp数组中的最大值,dp数组中的下标和给定数组的下标一一对应。
对于dp数组中的计算,核心想法如下:
对于dp数组中的每一位,都包括了A[i]之前所有子序列的最大值(注意,是所有情况下的最大值)。但是该问题下,有一个细则,就是对于dp[n]可能性的考虑。
对于dp[n]有两种情况:
1.dp[n]为A[n]
2.dp[n]为A[n]+dp[n-1]
对于第一种情况来说,原因也有两种,其一是可能元素确定之后有一个是最大的。其二为进行dp[n-1]和A[n]的加和之后,发现dp[n-1]+A[n]<A[n],对于这种情况,则将要重新确立左端点,将A[n]来作为左端点,如若不这样,则子序列的和要小。
对于第二种情况来说,A[n]作为待定序列中的成员,不作为左端点。
综上所述,则有状态转移方程:
dp[i]=max{A[i],dp[i-1]+A[i]},边界为dp[0]=A[0]
最后,遍历dp中的所有元素,得到最大的元素,即为给定序列中最大子序列和。
从这里可以看出,dp[i]的计算并不会管前面的待定序列是什么样,而是只关心dp[i-1]的值,并且确定后不会改变,也就是具有状态的无后效性。也就是当前状态记录了历史信息,一旦之前的状态确定,就不会再改变,并且所有的后续状态也会在这些已确定状态上进行生成。在设计状态和状态转移方程的时候,要设计拥有无后效性的状态以及状态转移方程,才能得到正确的结果。
代码如下:
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn=10010;
int A[maxn],dp[maxn];
void Maximum_continuous_subsequence_sum(){
int n;
scanf("%d",&n);
for(int i=0;i<n;i++){
scanf("%d",&A[i]);
}
dp[0]=A[0];
for(int i=1;i<n;i++){
dp[i]=max(A[i],dp[i-1]+A[i]);
}
int k=0;
for(int i=0;i<n;i++){
if(dp[i]>dp[k]){
k=i;
}
}
printf("%d\n",dp[k]);
}
int main()
{
Maximum_continuous_subsequence_sum();
system("pause");
return 0;
}
三、最长公共子序列(LCS)
这算是第一个在动态规划问题里遇到的一个经典的难以理解的问题。随着个人对于动态规划问题的了解深入,逐渐领会了两个重要的点:
1.最小问题的分解和求解,并且一定要按照特定次序来进行求解;
2.对于状态转移方程的确立,这是最大的难点。
所谓最长公共子序列,就是对于两个给定的字符串或者数字序列A、B,求一个字符串,使得这个字符串是A、B的最长公共部分,并且可以不连续。例如“sadstory”,“adminsorry”,这两个字符串的最长公共子序列为"adsory"。
对于这个问题,如果没有动态规划思想,最浅显的解法就是使用暴力穷举,来逐个比较,这样时间复杂度会很大。
对于动态规划来说,可以缩减到O(mn)的级别。
首先就是进行问题分析,对于dp数组,我们可以进行定义,定义dp数组为两个字符串的最长公共子序列的长度,也就是一个int定值。由于是两个序列,所以我们可以将dp建立为二维数组。其中,dp[i][j]代表A和B中前i和前j个字母序列中的最长子序列。此时,就可以对问题进行了一个状态分解,也就是通过每一次ij个元素的最长公共子序列的长度检测时,都可以检测其子序列的所对应的dp值。
对于dp值的判定,我们可以分为两种情况:
其一:当A的第i个和B中的第j个元素相等时,就可以使前个字母的dp值+1,所以dp[i][j]=dp[i-1][j-1]+1;
其二:当A的第i个和B中的第j个元素不相等时,就要向前来遍历其子序列的对应最大公共子序列长度值dp,从而进行dp[i][j]更新。但是由于i-1个和j-1个元素可能不同,所以dp[i][j]=max{dp[i-1][j],dp[i][j-1]}。
此时,状态转移方程就已经确立完成。
接下来就是如何进行子问题的向上递归求解问题,也就是避免在使用前面的dp值时,该值未被求出。对于dp数组来说,他就是一个二维表,通过循环更新就可以解决这个问题。所以只需要进行简单的循环嵌套就可以解决。因为寻找的元素为dp[i-1][j-1],dp[i][j-1],dp[i-1][j],所以可以轻松解决。
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
using namespace std;
const int N=100;
char A[N],B[N];
int dp[N][N];
void LCS(){
gets(A+1);
gets(B+1);
int lenA=strlen(A+1);
int lenB=strlen(B+1);
for(int i=0;i<=lenA;i++){
dp[i][0]=0;
}
for(int i=0;i<=lenB;i++){
dp[0][i]=0;
}
//状态转移方程
for(int i=1;i<=lenA;i++){
for(int j=1;j<=lenB;j++){
if(A[i]==B[j]){
dp[i][j]=dp[i-1][j-1]+1;
}else{
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
}
}
}
printf("%d\n",dp[lenA][lenB]);
}
int main()
{
LCS();
system("pause");
return 0;
}
对于更多的LCS理解。
四、最长回文子串(Longest Palindrome)
最长回文子串也是一个经典问题,其题目为:给出一个字符串S,求S的最长回文子串的长度。
对于这个问题,动态规划思想也十分有效,其基本的思想为,对于一个子串,如果其第二位和倒数第二位所构成的子串也是回文子串,并且第一个字符和最后一个字符相等,则该字符串必为回文子串。因此,可以自定义dp数组。由于会从字符串的首部和尾部来进行定位,所以,dp数组为二维数组,其中,dp[i][j]意义为字符串的第i位至字符串的第j位所构成的子串是否是回文子串。
对于初始dp来说,一个字符本身就是回文子串,所以dp[i][i]=1;不是回文子串,则dp[i][j]=0;
因此有状态转移方程:
dp[i][j]=dp[i+1][j-1],S[i]=S[j];
dp[i][j]=0,S[i]!=S[j];
对于老生常谈的初始化问题,也很简单,为了使出现未求解结果不会出现,因此先从单个字符串来开始更新dp数组,并且每次将测量的子串长度加一,从而进行迭代更新。最后去得到最大的长度。
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
using namespace std;
const int maxn=1010;
char S[maxn];
int dp[maxn][maxn];
int work_function(){
gets(S);
int len=strlen(S),ans=1;
memset(dp,0,sizeof(dp));
for(int i=0;i<len;i++){
dp[i][i]=1;
if(i<len-1){
if(S[i]==S[i+1]){
dp[i][i+1]=1;
ans=2;
}
}
}
for(int L=3;L<=len;L++){
for(int i=0;i+L-1<len;i++){
int j=i+L-1;
if(S[i]==S[j]&&dp[i+1][j-1]==1){
dp[i][j]=1;
ans=L;
}
}
}
printf("%d\n",ans);
}
int main()
{
work_function();
system("pause");
return 0;
}
五、最长不下降子序列(LIS)
最长不下降子序列问题描述如下:在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列不是下降的。
其基本的核心思想就是如何构建dp数组和状态转换方程。对于该问题,书上所给的经典思路如下:
令dp[i]代表以A[i]结尾的最长不下降子序列长度。因此,对于dp[i],有两种情况:
其一:如果存在A[j],在A[i]之前,并且小于A[i],此时,可以将A[i]拼接到A[j]后面,从而构成一个更长的子序列。但是注意,也要满足dp[j]+1>dp[i],其目的是A[i]拼接后的子序列长度应该大于直接以A[i]结尾的子序列长度(其实无关紧要,因为初始的dp长度各个都为1);
其二:如果A[i]之前的所有元素都比A[j]大,则A[i]形成自己的一条LIS,长度为1;
所以有状态转移方程dp[i]=max{1,dp[j]+1} (j=1,2,...,i-1&&A[j]<A[i])
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
using namespace std;
const int N=100;
int A[N],dp[N];
void LIS(){
int n;
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%d",&A[i]);
}
int ans=-1;
for(int i=1;i<=n;i++){
dp[i]=1;
for(int j=1;j<i;j++){
if(A[i]>=A[j]&&(dp[j]+1>dp[i])){
dp[i]=dp[j]+1;
}
}
ans=max(ans,dp[i]);
}
printf("%d",ans);
}
int main()
{
LIS();
system("pause");
return 0;
}