一、算法思想
动态规划(Dynamic Programing,DP)是一种用来解决一类最优化问题的算法思想,将一个复杂的问题分解为若干个子问题,通过综合子问题的最优解来得到原问题的最优解。关键是动态规划会将求解过的子问题的解记录下来,当下一次碰到同样问题时可以使用之前记录的结果。一般可以用递归或者递推的写法来实现动态规划。
以数塔为例,从顶部出发在每一个节点可以选择向左或者向右走,一直走到底层,要求找出一条路径,使得路径上的数字之和最大。如果穷举所有的路径,时间复杂度为O(2n)。

事实上穷举路径的过程中产生了大量的计算,图中7到底层的路径被反复的访问,不妨令 dp[i][j] 表示从第 i 行第 j 个数字出发的到达最底层所有路径中所能得到的最大和,例如 dp[3][2] 就是7到达最底层的路径最大和,dp[1][1] 即为所求。
d p [ 1 ] [ 1 ] = m a x { d p [ 2 ] [ 1 ] , d p [ 2 ] [ 2 ] } + f [ 1 ] [ 1 ] dp[1][1] = max\left\{dp[2][1], dp[2][2]\right\} + f[1][1] dp[1][1]=max{dp[2][1],dp[2][2]}+f[1][1]
d p [ i ] [ j ] = m a x { d p [ i + 1 ] [ j ] , d p [ i + 1 ] [ j + 1 ] } + f [ i ] [ j ] dp[i][j] = max\left\{dp[i+1][j], dp[i+1][j+1]\right\} + f[i][j] dp[i][j]=max{dp[i+1][j],dp[i+1][j+1]}+f[i][j]
dp[i][j]称为问题的状态,上面的式子为状态转移方程,直接确定结果的部分称为边界,而动态规划的递推写法总是从这些边界出发,通过状态转移方程扩散到整个 dp 数组。如果一个问题的最优解可以由其子问题的最优解有效地构造出来,那么称这个问题具有最优子结构,状态的无后效性是指一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或几个状态的基础上进行。
递归同样可以实现上面的例子,两者区别在于:递推是自底向上,即从边界开始不断向上解决目标问题;递归则是自顶向下,即从目标问题开始,将它分解成子问题的组合,知道分解至边界为止。
一个问题必须拥有重叠子问题和最优子结构才能使用动态规划去解决,其与分治法和贪心算法区别如下:
- 分治法和动态规划 :分治法和动态规划都是将问题分解为子问题,然后合并子问题的解得到原问题的解,但是分治法分解出的子问题是不重叠的,因此分治法解决的问题不拥有重叠子问题,而动态规划解决的问题拥有重叠子问题。例如,归并排序和快速排序都是分别处理左序列和右序列,然后合并左右序列结果不出现重叠子问题,因此是分治法。另外分治法解决的问题不一定是最优化问题,而动态规划解决的一定是最优化问题。
- 贪心与动态规划:贪心和动态规划都要求原问题具有最优的子结构。二者区别在于贪心采用的计算方式类似于上面介绍的自顶向下,但是并不等待求解完毕后再选择使用哪一个,而是通过一个策略直接选择一个子问题求解,没有被选择的子问题就不去求解。例如,数塔问题中贪心法中从最上层开始,每次选择左下和右下两个数字中较大的一个,一直到最底层得到最后结果,显然不一定得到最优解。而动态规划总是考虑所有的子问题,并选择能继承得到最优结果的那个,对于暂时没被继承的子问题,由于重叠子问题的存在,后期可能会再次考虑它们,因此还有可能成为全局最优。
二、DP问题
1. 最大连续子序列和
最大连续子序列和问题描述如下:
给定一个数字序列A1,A2,……,An,求i,j(1 ≤ i ≤ j ≤ n),使得 Ai + Aj 最大,输出这个最大和。
例如 -2,11,-4,13,-5,-2,输出 20
分析: 一个连续数组一定要以一个数作为结尾,即 dp[i]
中一定包含数组 array[i]
,而且很有可能 dp[i]
与 dp[i -1]
所表示的连续子序列就差一个 array[i]
。但是如果 dp[i] 是一个负数,那么递推求得的就不是最大和,此时dp[i]
的最大值应该是array[i]
的值。综上求解办法如下:
- 令状态
dp[i]
表示以array[i]
为末尾的连续序列的最大和,最后要求的最大值即为 dp 数组的最大值;
dp[0] = -2
dp[1] = 11
dp[2] = 11 - 4 =7
dp[3] = 11 - 4 + 13 = 20
dp[4] = 15(必须包含array [4])
dp[i]
是以array[i]
为结尾的连续序列,边界为dp[0] = array[0]
;
d p [ i ] = m a x { a r r a y [ i ] , d p [ i − 1 ] + a r r a y [ i ] } dp[i] = max \left\{array[i], dp[i-1] + array[i] \right \} dp[i]=max{array[i],dp[i−1]+array[i]}
代码如下:
/*
状态:以第i个元素为结尾的连续子序列(注意,第i个元素一定要被选择)
状态转移方程:以第i个元素结尾的最大连续子序列有两种情况:
1、以前一个元素结尾的最优连续子序列 + 第i个元素
2、只有第i个元素
*/
#include<stdio.h>
int array[100], dp[100];
int main()
{
int n, i;
scanf("%d", &n);
for(i = 0; i < n; i++)
scanf("%d", &array[i]);
dp[0] = array[0];
int max = array[0];
for(i = 1; i < n; i++)
{
if(dp[i-1] < 0)
dp[i] = array[i];
else
dp[i] = dp[i-1] + array[i];
max = dp[i] > max ? dp[i]:max;
}
printf("%d\n", max);
return 0;
}
2. 最长不下降子序列(LIS)
最长不下降子序列问题描述如下:
在一个数字序列中,找到一个最长的非递减子序列(可以不连续)
例如 ,序列{1, 2, 3, -1, -2, 7, 9},最长不下降子序列为{1, 2, 3, 7, 9}
分析: LIS可能是非连续的,且符合重叠子问题和最优子结构,定义状态 dp[i]
表示以 array[i]
结尾的最长不下降子序列长度。那么存在两种情况:
- 如果存在
array[i]
之前的元素array[j] (j < i)
,使得array[j] ≤ array[i]
,且dp[j] + 1 > dp[i]
,那么dp[i] = dp[j] + 1
; - 如果
array[i]
之前的元素都比array[i]
大,那么长度为1,即子序列中只有一个array[i]
;
d p [ i ] = m a x { 1 , d p [ j ] + 1 } dp[i] = max\left\{1, dp[j] + 1\right\} dp[i]=max{1,dp[j]+1}
( j = 1 , 2 , … , i − 1 & & a r r a y [ j ] < a r r a y [ i ] ) (j = 1,2,…,i-1 \&\& array[j] < array[i]) (j=1,2,…,i−1&&array[j]<array[i])
dp 数组中最大值即为整个序列的 LIS 长度,代码如下:
/*
状态:dp[i]存放以第i个元素结尾时最长的非递减子序列元素个数
状态转移方程:以第i个元素结尾的最长的非递减子序列元素个数:
依次检查前面已经解过的所有问题,如果A[j] <= A[i],说明出现了一种情况,再判断dp[j] + 1 > dp[i],如果大于说明出现了更优的解;
*/
#include<stdio.h>
int array[100], dp[100];
int main()
{
int n, i, j;
scanf("%d", &n);
for(i = 0; i < n; i++)
scanf("%d", &array[i]);
int max = -1;
for(i = 0; i < n; i++)
{
dp[i] = 1;
for(j = 0; j < i; j++)
{
if(array[i] >= array[j] && (dp[j] + 1 > dp[i]))
dp[i] = dp[j] + 1;
}
max = dp[i] > max ? dp[i]:max;
}
printf("%d\n", max);
return 0;
}
3. 最长公共子序列(LCS)
最长公共子序列问题描述如下:
给定两个字符串(或数字序列)A 和 B,求一个字符串,使得这个字符串是 A 和 B 的最长公共部分(子序列可以不连续)
如字符串 sadstory 与字符串 adminsorry 的最长公共子序列 adsory 长度为6
分析: 令dp[i][j]
表示 A 的 i 号位与 B 的 j 号位之前的 LCS 长度,下标从 1 开始,如dp[4][5]
表示 sads 与 admin 的 LCS 长度,可以根据A[i]与B[j]的情况,分为一下两种决策。
- 若
A[i] == B[j]
,则字符串 A 与字符串 B 的LCS增加了1位,即有dp[i][j] = dp[i-1][j-1] + 1
。
l例如,dp[4][6] 表示 sads 与 admins 的 LCS 长度,比较 A[4] 与 B[6] 都为s,则 dp[4][6] = dp[3][5] + 1.即为3
- 如果
A[i] != B[i]
则 A 的 i 号位与 B 的 j 号位之前的 LCS 无法延长,因此dp[i][j]
会继承dp[i][j-1]
与dp[i-1][j]
中的较大值。
例如,dp[3][3] 表示 sad 与 adm 的LCS长度,比较 A[3] 与 B[3] 发现d不等于m,这样 dp[3][3] 无法在原先的基础上延长,因此继承自 sa 与 adm 的LCS,sad 与 ad 的 LCS 中的较大值,即 sad 与 ad 的 LCS 长度:2。
d p [ i ] [ j ] = { d p [ i − 1 ] [ j − 1 ] + 1 , A [ i ] = = B [ j ] m a x { d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] } , A [ i ] ! = B [ j ] dp[i][j] = \begin{cases} dp[i-1][j-1] + 1, & A[i] == B[j] \\ max\{dp[i-1][j], dp[i][j-1]\}, & A[i] != B[j] \end{cases} dp[i][j]={dp[i−1][j−1]+1,max{dp[i−1][j],dp[i][j−1]},A[i]==B[j]A[i]!=B[j]
dp[i][j]
只与其当前的状态有关, 由边界出发可以得到整个 dp 数组,最终dp[n][m]
就是需要的答案,时间复杂度O(mn)。
/*
状态:dp[i][j] 存放以 s1 第 i 个元素结尾,s2 第 j 个元素结尾时两个字符串最长的公共子序列元素个数
状态转移方程:
1)当A[i] == B[j]时,dp[i][j] = dp[i-1][j-1] + 1
2)当A[i] != B[j]时,dp[i][j] = max(dp[i-1][j], dp[i][j-1])
边界 dp[i][0] = dp[0][j] = 0(0 <= i <= n, 0 <= j <= m)
*/
#include<stdio.h>
#include<string.h>
int dp[100][100] = {0};
char s1[100], s2[100];
int main()
{
scanf("%s%s",s1,s2);
int len1 = strlen(s1), len2 = strlen(s2);
int i, j;
for(i = 0; i < len1; i++)
{
for(j = 0; j < len2; j++)
{
if(s1[i] == s2[j])
dp[i][j] = dp[i-1][j-1] + 1;
else
dp[i][j] = dp[i-1][j] > dp[i][j-1]?dp[i-1][j]:dp[i][j-1];
}
}
printf("%d\n",dp[len1-1][len2-1]);
return 0;
}
4. 最长回文子串
最长回文子串问题描述如下:
给定一个字符串 S,求 S 的最长回文子串的长度。
例如,字符串 PATZJUJZTACCBCC 的最长回文子串为 ATZJUJZTA,长度为9。
**分析:令dp[i][j]
表示s[i]
至s[j]
所表示的子串是否是回文子串,是则为 1,不是则为 0。可以分为两种情况
- 若
s[i] == s[j]
,那么只要s[i+1]
至s[j-1]
是回文串,则s[i]
到s[j]
是回文串。如果s[i+1]
到s[j-1]
不是回文串,那么s[i]
至s[j]
也不是回文串。 - 若
s[i] != s[j]
,那么s[i]
到s[j]
一定不是回文串。
状态转移方程:
d p [ i ] [ j ] = { d p [ i + 1 ] [ j − 1 ] + 1 , s [ i ] = = s [ j ] 0 , s [ i ] ! = s [ j ] dp[i][j] = \begin{cases} dp[i+1][j-1] + 1, & s[i] == s[j] \\ 0, & s[i] != s[j] \end{cases} dp[i][j]={dp[i+1][j−1]+1,0,s[i]==s[j]s[i]!=s[j]
边界: d p [ i ] [ i ] = 1 , d p [ i ] [ i + 1 ] = ( s [ i ] = = s [ i + 1 ] ) ? 1 : 0 dp[i][i] = 1, dp[i][i+1] = (s[i] == s[i+1])?1 : 0 dp[i][i]=1,dp[i][i+1]=(s[i]==s[i+1])?1:0
如果按照 i 和 j 的从小到大的顺序来枚举两个子串的两个端点,然后更新dp[i][j]
,会无法保证dp[i+1][j-1]
已经被计算过,从而无法得到正确的dp[i][j]
。如下图,C和C之间的D和D还没有计算。

由于边界表示为长度为1和2的子串,且每次转移时都对子串的长度减1,可以考虑按照子串的长度 与子串的初始位置进行遍历,如第一遍遍历长度为3的子串,第二遍再计算出长度为4的子串…,子串长度最多可以取到S.length()。代码如下:
#include<stdio.h>
#include<string.h>
int dp[100][100] = {0};
char str[100];
int main(){
gets(str);
int len = strlen(str),ans = 1;
//判断长度为2 的子串是否是回文串
for(int i = 0; i < len; i ++)
{
dp[i][i] = 1;
if(i < len - 1 && str[i] == str[i + 1])
{
dp[i][i + 1] = 1;
ans = 2;
}
}
//从长度为3开始,判断所有长度为k的子串是否是回文串
for(int k = 3; k <= len; k ++)
{
for(int i = 0; i + k - 1 < len; i ++)
{
int j = i + k - 1;
//如果这个串的首元素等于尾元素,并且长度减二的子串也是回文串,说明这个字符串也是回文串
if(str[i] == str[j] && dp[i + 1][j - 1] == 1)
{
//更新长度
ans = k;
dp[i][j] = 1;
}
}
}
printf("%d", ans);
return 0;
}
5. DAG关键路径
关键路径问题描述如下:
给定一个有向无环图,怎样求整个图的所有路径中权值最大的那条。
分析: 可以采用递归求解,由于从出度为 0 的顶点出发的最长路径为 0,因此边界的这些顶点的dp值为 0,但具体实现中可以对整个 dp 数组初始化为 0。遇到出度不是0的顶点则会递归求解,递归过程中遇见已经计算过的顶点则会直接返回对应的dp值,逻辑上按照了逆拓扑排序的程序。
int DP(int i)
{
if(dp[i] > 0)
return dp[i];
for(int j = 0; j < n; j++) //遍历 i 的所有出边
if(G[i][j] != INF)
dp[i] = max{dp[i], DP(j) + G[i][j]};
}
//调用printPath前需要先得到最大的dp[i],然后将 i 作为路径七点传入
void printPath(int i)
{
printf("%d", i);
while(choice[i] != -1)
{
i = choice[i];
printf("%d", i);
}
}
接下来用动态规划求解,令dp[i]
表示从 i 号顶点出发到达终点 T 所获得的最长路径长度,如果从 i 号定点直接出发能到达 j1,j2,……,jk,而 dp[jk]均已知,dp[i] = max{ dp[j] + length[i → j]|(i, j∈E)}
。首先初始化一个 dp 数组为负无穷大,来保证无法到达终点。然后设置一个 vis 数组表达盯电脑已经被访问。关键代码如下:
int DP(int i) {
if (dp[i] > 0)return dp[i];//dp[i]已经得到
for (int j = 0; j < n; j++) {
if (G[i][j] != inf) {
dp[i] = max(dp[i], dp[j] + G[i][j]);
}
}
return dp[i];
}