动态规划入门指南
本文将持续更新,若有任何疑问,欢迎留言 (*^_^*)
一直以来,动态规划问题都是算法设计中的难点。由于动态规划的思考方式与人脑自然的思考方式有着较大的差异,甚至与一般算法问题的思考方式也有很大的不同。所以,动态规划问题对思维能力有很高的要求。提高思维能力,掌握一种有效的思维方式,在求解动态规划问题中,就显得尤为重要。
什么是动态规划
动态规划(英语:Dynamic programming,简称DP)是一种在计算机科学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划常常适用于 有重叠子问题 和 最优子结构 性质的问题,动态规划方法所耗时间往往远少于朴素解法。
这是在 Wikipedia 中的解释。
动态规划本质上是一种思想,而非严格意义上的算法。其核心思想是将 有重叠子问题 和 最优子结构 性质的复杂问题,分解为多个相似的子问题。因此,每个子问题仅需解决一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题的解时直接查表。
接下来将从 有重叠子问题 和 最优子结构 这两个性质切入,分析一些经典的动态规划问题。
从简单的问题开始
例1:数字三角形
题目描述
给定一个如下图所示的数字三角形,从顶部出发,在每一结点可以选择移动至其左下方的结点或移动至其右下方的结点,一直走到底层,要求找出一条路径,使路径上的数字的和最大。
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
输入格式
第一行包含整数 n,表示数字三角形的层数。
接下来 n 行,每行包含若干整数,其中第 i 行表示数字三角形第 i 层包含的整数。
输出格式
输出一个整数,表示最大的路径数字和。
输入样例
3
1
2 3
3 1 4
输出样例
8
数据范围
1 ≤ n ≤ 500,
−
10000
−10000
−10000 ≤ 三角形中的整数 ≤
10000
10000
10000
解题分析
在这道题中,所需求解的原问题是:寻找一条从 ( 1 , 1 ) (1,1) (1,1) 到 ( n , j ) 1 ≤ j ≤ n (n,j)\ 1 \le j \le n (n,j) 1≤j≤n 的路径,使得路径上所经过数字的和最大。
第一步拆分
经过分析发现,原问题所求的是 所有到达最后一行
n
n
n 中每一个数的最大的路径当中,最大的那一条,即
m
a
x
(
f
[
n
,
j
]
)
1
≤
j
≤
n
max(f[n, j])\ 1 \le j \le n
max(f[n,j]) 1≤j≤n,
f
[
n
,
j
]
f[n,j]
f[n,j] 为到达当前位置
(
n
,
j
)
(n,j)
(n,j) 的最大值。
加粗文字较难理解,请结合例子与表达式进行思考。
举个例子,如下图所示:
从 ( 1 , 1 ) (1,1) (1,1) 到 ( 3 , 1 ) (3,1) (3,1) 的最大值为 6 6 6 ,从 ( 1 , 1 ) (1,1) (1,1) 到 ( 3 , 2 ) (3,2) (3,2) 的最大值为 5 5 5 ,从 ( 1 , 1 ) (1,1) (1,1) 到 ( 3 , 3 ) (3,3) (3,3) 的最大值为 8 8 8 。根据题意,原问题的解为从 ( 1 , 1 ) (1,1) (1,1) 到 ( 3 , 1 ) , ( 3 , 2 ) , ( 3 , 3 ) (3,1),(3,2),(3,3) (3,1),(3,2),(3,3) 中的最大路径的最大的值,故本题的解为 8 8 8 。
第二步拆分
如下图所示,从
i
i
i 行到
i
+
1
i+1
i+1 行有两种方式, 一种是向左下走,另一种是向右下走。同理
(
i
,
j
)
(i,j)
(i,j) 可由
(
i
−
1
,
j
)
(i-1,j)
(i−1,j) 和
(
i
−
1
,
j
−
1
)
(i-1,j-1)
(i−1,j−1) 到达。
因此,可将第一步所拆分的子问题(
m
a
x
(
f
[
n
,
j
]
)
max(f[n, j])
max(f[n,j]) )继续拆分成到达
(
i
,
j
)
(i,j)
(i,j) 的最大值
f
[
i
,
j
]
f[i,j]
f[i,j]。因为所求的值为最大值,若使得
f
[
i
,
j
]
f[i,j]
f[i,j] 最大,则需选则两种转移方案
f
(
i
−
1
,
j
)
f(i-1,j)
f(i−1,j) 和
f
(
i
−
1
,
j
−
1
)
f(i-1,j-1)
f(i−1,j−1) 中值较大的一种。能够这样转移的关键在于,这道题满足最优子结构性质,即原问题所有转移方案中最优的方案,仍为原问题的最优解。
由此可以得出动态转移方程: f [ i , j ] = m a x ( f [ i − 1 , j ] , f [ i − 1 , j − 1 ] ) + a [ i , j ] f[i, j] = max(f[i - 1, j], f[i - 1, j - 1]) + a[i,j] f[i,j]=max(f[i−1,j],f[i−1,j−1])+a[i,j],其中 a [ i , j ] a[i,j] a[i,j] 为 ( i , j ) (i,j) (i,j) 位置上的数。
本道例题及后面所有例题的参考代码,都将见于文末。
例2:最长上升子序列 (LIS)
题目描述
给定一个长度为 N 的数列,求数值严格单调递增的子序列的长度最长是多少。
输入格式
第一行包含整数N。
第二行包含N个整数,表示完整序列。
输出格式
输出一个整数,表示最大长度。
输入样例
7
3 1 2 1 8 5 6
输出样例
4
数据范围
1 ≤ N ≤ 1000,
−
1
0
9
−10^9
−109 ≤ 数列中的数 ≤
1
0
9
10^9
109
解题分析
首先,对 子序列 这个概念做下解释。子序列 是指原序列中选出一些数,在不该变原有顺序的条件下,所构成的序列。例如:原序列为 3 1 2 1 8 5 6
,3 1 1 6
是其子序列,1 2 5 6
同样也是其子序列。子序列中的数 不要求 在原序列中紧密相连。
然后,我们对原问题进行拆分。由于需要准确地表示所拆出来的子问题,我们定义 f [ i ] f[i] f[i] 为以下标是 i i i 的数字做为子序列的结尾,所有合法子序列长度中的最大值。
如何计算出 f [ i ] f[i] f[i] 呢?在动态规划问题中,求解未知的子问题时,常常通过已求解出的子问题的解来得到未求解出问题的解。
当我们在求解 f [ i ] f[i] f[i] 时,已知的是所有下标 j j j 在 1 ≤ j < i 1 \le j < i 1≤j<i 范围内,以下标为 j j j 结尾的子序列长度的最大值。 f [ i ] f[i] f[i] 所能够转移到的状态需满足数字 a [ i ] a[i] a[i] 大于数字 a [ j ] a[j] a[j] 。
所以, f [ j ] f[j] f[j] 应为所有能够转移的状态中,子序列的最长长度加 1 1 1 。
可得到状态转移方程式: f [ i ] = m a x ( f [ i ] , f [ j ] + 1 ) ( 1 ≤ j < i , a [ j ] < a [ i ] ) f[ i ] = max ( f [ i ],f[j] + 1) (1 \le j < i,a[ j ] < a[ i ]) f[i]=max(f[i],f[j]+1)(1≤j<i,a[j]<a[i]) 。其中 a a a 为原序列。
在动态规划中,我们将问题的表示称为 状态表示 ,将求解问题的过程称为 状态计算 ,将原问题转化成子问题称为 状态转移 。
到这里,我们发现每一个状态,也就是子问题,都是包含所有可能的转移方式,存储这些转移方式在当前状态下的最优值。其中蕴含了集合的思想。对于状态表示,也可以理解为将集合进行划分,划分成已知的子问题,来进行求解。这就是接下来将要介绍的动态规划问题思考方法。
参考代码
例1
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 510, INF = 1e9;
int n, a[N][N], f[N][N];
int main()
{
scanf("%d", &n);
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= i; j ++ )
scanf("%d", &a[i][j]);
for (int i = 0; i <= n; i ++ )
for (int j = 0; j <= i + 1; j ++ )
f[i][j] = -INF;
f[1][1] = a[1][1];
for (int i = 2; i <= n; i ++ )
for (int j = 1; j <= i; j ++ )
f[i][j] = max(f[i - 1][j - 1] + a[i][j], f[i - 1][j] + a[i][j]);
int res = -INF;
for (int i = 1; i <= n; i ++ )
res = max(res, f[n][i]);
printf("%d\n", res);
return 0;
}
例2
#include <iostream>
#include <cstdio>
using namespace std;
int f[1010], a[1010], n;
int main()
{
scanf("%d", &n);
for (int i = 1; i <= n; i++)
scanf("%d", &a[i]);
for (int i = 1; i <= n; i++)
{
f[i] = 1;
for (int j = 1; j < i; j++)
if (a[i] > a[j])
f[i] = max(f[i], f[j] + 1);
}
int res = -1;
for (int i = 1; i <= n; i++)
res = max(res, f[i]);
printf("%d", res);
return 0;
}