写在前面
动态规划这一类问题非常灵活,而且其下有很多子问题,由于自己水平有限,这里只比较基础的介绍动态规划,并带大家感性的认识一下什么是动态规划,为什么短短数行代码,会有如此大的威力?
1.1 定义
对于动态规划的规范性定义可以参考维基百科:动态规划。
动态规划(英语:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
————引自维基百科
对于动态规划,很难用很通俗的话语讲的清晰明白,或者损失其精确定义的准确性,亦或者无法把她的美展现出来,鉴于自己水平也有限,故通过几道例题,看读者是否能够从其中领会她的美丽。
1.2 例题分析
例题1.数字三角形
题目描述:
在上面的数字三角形中寻找一条从顶部到底边的路径,使得路径上所经过的数字之和最大。路径上的每一步都只能往左下或右下走。只需要求出这个最大和即可,不必给出具体路径。 三角形的行数大于1小于等于100,数字为 0 - 99
输入格式:
5 //表示三角形的行数 接下来输入三角形
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
// 要求输出最大和
输出格式:
30
分析:
首先,数字三角形必然是要一个二维数组来存储的,对于样例,我们可以分析:
第一行是7,那么必然入口是7;
第二行是3和8,那么此时有2个选择,往3走,则此时数字之和为10;往8走,则此时数字之和为15;
同样的,我们继续往下分析,第三行是8、1、0,那么此时有4个选择(即第二行的3可以选择往8、1走,第二行的8可以选择往1、0走),那么在这种情况下,当走到第三行的8时,此时数字之和为18;当走到第三行的1时,此时可以有两条路径走到这里,但题目要求的是最大和,故保存最大的和即可,也就是当走到第三行的1时,此时数字之和为16;当走到第三行的0时,此时数字之和为15;
而后第四行第五行呢?聪明的你是不是已经知道了?是的,依旧是按照上述方式分析即可。
那么根据以上的分析步骤,我们可以设计以下算法流程:
(1).申请一个与数字三角形同大小的二维数组,记为
sum
s
u
m
数组,其中元素初始化为0,
sum
s
u
m
数组记录的是自顶向下的路径到数字三角形每个位置时的最大和。
(2).对于位置(i,j)处的最大和,有
sum[i][j]=max(sum[i−1][j],sum[i−1][j−1])+a[i][j]
s
u
m
[
i
]
[
j
]
=
m
a
x
(
s
u
m
[
i
−
1
]
[
j
]
,
s
u
m
[
i
−
1
]
[
j
−
1
]
)
+
a
[
i
]
[
j
]
(3).重复步骤(2),直到最后一行,遍历最后一行的所有
sum
s
u
m
数组元素,求出最大值
于是我们得到解法1的完整代码如下:
#include <cstdio>
#include <cstring>
#define MAX(a,b) (a > b ? a : b)
const int MAX_SIZE = 105;
int a[MAX_SIZE][MAX_SIZE], sum[MAX_SIZE][MAX_SIZE];
int solve(int n) {
for (int i = 1; i <= n;i++) {
for (int j = 1;j <= i;j++) {
scanf("%d", &a[i][j]);
}
}
for (int i = 1;i <= n;i++) {
for (int j = 1;j <= i;j++) {
sum[i][j] = MAX(sum[i-1][j], sum[i-1][j-1]) + a[i][j];
}
}
int ans = sum[n][1];
for (int i = 2; i <= n;i++) {
ans = MAX(ans, sum[n][i]);
}
return ans;
}
int main() {
int n;
while (scanf("%d", &n) != EOF) {
memset(sum, 0, sizeof(sum));
printf("%d\n", solve(n));
}
return 0;
}
上述思路似乎是比较容易想到的一种思路,这样做就将该问题视作常规的“贪心”问题求解,那么我们换个角度,是否有其他的做法呢?
试想,无论从哪个点开始往下走,都只能往左下或者右下走,因此我们可以尝试采用递归方法解决这个问题,故记
maxSum(i,j)
m
a
x
S
u
m
(
i
,
j
)
为从位置
(i,j)
(
i
,
j
)
处到底部的最大和,那么我们最初的问题即为
maxSum(1,1)
m
a
x
S
u
m
(
1
,
1
)
,而由于从每个点都是只能往左下或者右下走,因此我们可以得到递归关系式:
当 i==n i == n 时,也就是到了三角形的底部,它们的最大和也就是它们自身,这也就是递归终止的条件:
if (i == n) {
maxSum(i, j) = a[i][j];
}
于是我们得到解法2的完整代码如下:
#include <cstdio>
#define MAX(a,b) (a > b ? a : b)
const int MAX_SIZE = 105;
int a[MAX_SIZE][MAX_SIZE];
int maxSum(int i, int j, int n) {
if (i == n) {
return a[i][j];
} else {
return MAX(maxSum(i+1, j+1, n), maxSum(i+1, j, n)) + a[i][j];
}
}
int main() {
int n;
while (scanf("%d", &n) != EOF) {
for (int i = 1; i <= n;i++) {
for (int j = 1;j <= i;j++) {
scanf("%d", &a[i][j]);
}
}
printf("%d\n", maxSum(1, 1, n));
}
return 0;
}
但是,代码超时了,实际上也在意料之中,原因是存在大量的重复计算,以本题为例:
从上图可知,采用递归的方法求解,时间复杂度是指数级别的(
O(2n)
O
(
2
n
)
),而本题的问题规模为
n≤100
n
≤
100
,因此必然是超时的。那么我们应该如何改进呢?
试想,既然存在大量重复运算,那么直接保存每次运算好的结果不就可以了吗?因此我们每算得一次
maxSum(i,j)
m
a
x
S
u
m
(
i
,
j
)
就将其保存起来,这样的话,等到要用到的时候,则可以以
O(1)
O
(
1
)
的时间取的,由于
n
n
层的“金字塔”总共有个
maxSum
m
a
x
S
u
m
值,故可将上述递归的时间复杂度降至
O(n2)
O
(
n
2
)
。
于是我们得到解法3的完整代码如下:
#include <cstdio>
#define MAX(a,b) (a > b ? a : b)
const int MAX_SIZE = 105;
int a[MAX_SIZE][MAX_SIZE], sum[MAX_SIZE][MAX_SIZE];
int maxSum(int i, int j, int n) {
if (sum[i][j] != 0) {
return sum[i][j];
}
if (i == n) {
sum[i][j] = a[i][j];
} else {
int x = maxSum(i+1, j+1, n);
int y = maxSum(i+1, j, n);
sum[i][j] = MAX(x, y) + a[i][j];
}
return sum[i][j];
}
int main() {
int n;
while (scanf("%d", &n) != EOF) {
for (int i = 1; i <= n;i++) {
for (int j = 1;j <= i;j++) {
scanf("%d", &a[i][j]);
}
}
printf("%d\n", maxSum(1, 1, n));
}
return 0;
}
上述代码虽然比较完美的解决了问题,但是递归总是需要使用大量堆栈上的空间,很容易造成栈溢出。因此我们思考如何将递归型的代码转化成递推型的代码。
首先需要计算的是最后一行,而最后一行的
maxSum
m
a
x
S
u
m
值就等于它们自身,因此可以把最后一行直接写出。
其次是计算倒数第二行,首先数字2,2可以和最后一行4相加,也可以和最后一行的5相加,和5相加时的结果要更大,因此结果为7,此时就可以将7保存起来,然后分析数字7,7可以和最后一行的5相加,也可以和最后一行的2相加,和5相加更大,结果为12,因此我们将12保存起来。以此类推得到倒数第二行的
maxSum
m
a
x
S
u
m
结果:
再然后是按照同样的思路处理倒数第三行和倒数第四行,以及推得最后一行,也就是我们的金字塔“塔尖”,如下:
有了以上的分析过程,我们就可以把以上递归型程序稍加改造一下。
于是我们得到解法4的完整代码如下:
#include <cstdio>
#define MAX(a,b) (a > b ? a : b)
const int MAX_SIZE = 105;
int a[MAX_SIZE][MAX_SIZE], sum[MAX_SIZE][MAX_SIZE];
int maxSum(int n) {
for (int i = 1;i <= n;i++) {
sum[n][i] = a[n][i];
}
for (int i = n-1;i >= 1;i--) {
for (int j = 1; j <= n;j++) {
sum[i][j] = MAX(sum[i+1][j], sum[i+1][j+1]) + a[i][j];
}
}
return sum[1][1];
}
int main() {
int n;
while (scanf("%d", &n) != EOF) {
for (int i = 1; i <= n;i++) {
for (int j = 1;j <= i;j++) {
scanf("%d", &a[i][j]);
}
}
printf("%d\n", maxSum(n));
}
return 0;
}
到了这里,问题算是比较圆满的完成了,但是是否能够继续优化呢?试想,我们要得到的结果只是
maxSum(1,1)
m
a
x
S
u
m
(
1
,
1
)
,那么中间结果是否有保存的需要呢?由于我们是从底层一行行地向上递推,那么只要一维数组
maxSum[100]
m
a
x
S
u
m
[
100
]
即可,即只要存储一行的
maxSum
m
a
x
S
u
m
值即可。对于空间优化后的具体递推过程如下:
再进一步考虑,是否有需要申请
maxSum
m
a
x
S
u
m
数组的必要呢?试想,金字塔最后一层刚好是n个元素,并且是从下往上递推的,因此直接用原数组最后一行代替
maxSum
m
a
x
S
u
m
数组即可。
于是我们得到解法5的完整代码如下:
#include <cstdio>
#define MAX(a,b) (a > b ? a : b)
const int MAX_SIZE = 105;
int a[MAX_SIZE][MAX_SIZE];
int maxSum(int n) {
int *sum = a[n];
for (int i = n-1;i >= 1;i--) {
for (int j = 1; j <= n;j++) {
sum[j] = MAX(sum[j], sum[j+1]) + a[i][j];
}
}
return sum[1];
}
int main() {
int n;
while (scanf("%d", &n) != EOF) {
for (int i = 1; i <= n;i++) {
for (int j = 1;j <= i;j++) {
scanf("%d", &a[i][j]);
}
}
printf("%d\n", maxSum(n));
}
return 0;
}
当然,以上的做法只是对程序的空间复杂度做了一定的改进,但是时间复杂度却没有发生变化,依然是
O(n2)
O
(
n
2
)
,而且在一定程度上相对于解法4降低了程序的可读性,使得程序更加难以读懂。但是这个优化的过程值得我们好好思考和体会。到了这里,这道题差不多解析的比较完全了,但是我们这一章介绍的是动态规划的知识,那么哪个程序涉及到了呢?对比解法1和解法4的核心代码如下:
解法1:
for (int i = 1;i <= n;i++) {
for (int j = 1;j <= i;j++) {
sum[i][j] = MAX(sum[i-1][j], sum[i-1][j-1]) + a[i][j];
}
}
解法4:
for (int i = n-1;i >= 1;i--) {
for (int j = 1; j <= n;j++) {
sum[i][j] = MAX(sum[i+1][j], sum[i+1][j+1]) + a[i][j];
}
}
解法一是从上往下依次递推,而解法二是从下往上递推。这两种解法在本质上是没有区别的,只不过定义问题的方式不一样,因而递推公式有所区别,那么这个跟动态规划有什么关系呢?我们再通过两道例题说明。
例题2.找零钱问题
小x买了个钱包,结果买完就没钱放了,一气之下将钱包搁置箱底,常常忘记带出来。但是没有钱包的话,纸币放在口袋里很不方便,容易乱,也不容易掉,所以每次有买什么东西的时候,他都会让收银员找给他最少张数的纸币。收银员忙于找零,经常没法顾及这个问题,所以求助会编程的你。
正如我们所知,纸币面值一般有1元,5元,10元,20元,50元,100元。
输入格式:
输入包含多组数据。
输入第一行包含一个整数 N(N<10000) N ( N < 10000 ) ,表示要找的零钱总额。
输出格式:
每次输出一个整数表示最少张数的纸币
SampleInput:
75
13
SampleOutput:
3
4
这道题我们在贪心算法的这一章节中有涉及,并且成功用贪心算法解决了这个问题。但是是否有另外的方法可以解决这个问题呢?在思考这个问题之前,我们先考虑一个更小的问题。
如果我们有面值为1元、3元和5元的硬币若干枚,如何用最少的硬币凑够11元?
首先我们思考一个问题,如何用最少的硬币凑够i元(i<11)?为什么要这么问呢? 有两个原因:
1.当我们遇到一个大问题时,总是习惯把问题的规模变小,这样便于分析讨论。
2.这个规模变小后的问题和原来的问题是同质的,除了规模变小,其它的都是一样的, 本质上它还是同一个问题(规模变小后的问题其实是原问题的子问题)。
当i=0,即我们需要多少个硬币来凑够0元。由于1,3,5都大于0,即没有比0小的币值,因此凑够0元我们最少需要0个硬币。 这时候我们发现用一个标记来表示这句“凑够0元我们最少需要0个硬币。”会比较方便, 如果一直用纯文字来表述,那么会显得比较累赘。于是, 我们用d(i)=j来表示凑够i元最少需要j个硬币。并且我们已经得到了d(0)=0, 表示凑够0元最小需要0个硬币。
当i=1时,只有面值为1元的硬币可用,因此我们拿起一个面值为1的硬币,接下来只需要凑够0元即可,而这个是已经知道答案的, 即d(0)=0。所以,d(1)=d(1-1)+1=d(0)+1=0+1=1。
当i=2时, 仍然只有面值为1的硬币可用,于是先拿起一个面值为1的硬币,接下来我只需要再凑够2-1=1元即可(记得要用最小的硬币数量),而这个答案也已经知道了。所以d(2)=d(2-1)+1=d(1)+1=1+1=2。
这时让我们看看i=3时的情况。当i=3时,我们能用的硬币就有两种了:1元的和3元的。 既然能用的硬币有两种,我就有两种方案。
- 如果我拿了一个1元的硬币,我的目标就变为了:凑够3-1=2元需要的最少硬币数量。即d(3)=d(3-1)+1=d(2)+1=2+1=3。这个方案说的是,我拿3个1元的硬币;
- 第二种方案是我拿起一个3元的硬币,我的目标就变成:凑够3-3=0元需要的最少硬币数量。即d(3)=d(3-3)+1=d(0)+1=0+1=1.这个方案说的是,我拿1个3元的硬币。
好了,这两种方案哪种更优呢?记得我们可是要用最少的硬币数量来凑够3元的。所以选择d(3)=1,怎么来的呢?具体是这样得到的:d(3)=min{d(3-1)+1, d(3-3)+1}
到了这里,我想你可能已经从以上的文字中有了一些模糊的想法, 那么我们要抽象出动态规划里非常重要的两个概念:状态和状态转移方程。
上文中d(i)表示凑够i元需要的最少硬币数量,我们将它定义为该问题的”状态”, 这个状态是怎么找出来的呢?从上述问题可知:根据子问题定义状态。你找到子问题,状态也就浮出水面了。最终我们要求解的问题,可以用这个状态来表示:d(11),即凑够11元最少需要多少个硬币。那状态转移方程是什么呢?既然我们用d(i)表示状态,那么状态转移方程自然包含d(i),上文中包含状态d(i)的方程是:d(3)=min{d(3-1)+1, d(3-3)+1}。没错, 它就是状态转移方程,描述状态之间是如何转移的。当然,我们要对它抽象一下,具体如下:
d(i)=min{d(i−vj)+1} d ( i ) = m i n { d ( i − v j ) + 1 } ,其中 i−vj≥0 i − v j ≥ 0 , vj v j 表示第 j j 个硬币的面值;
这时候,我们回到最初的找零钱问题,最初的问题相对于上文中我们分析的问题,只是零钱的面值发生了改变,故状态和状态转移方程依旧不变。因此不难得出完整代码如下:
#include <cstdio>
#include <cstring>
#define MIN(a, b) (a < b ? a : b)
const int MAX_SIZE = 1e4 + 5;
int sum[MAX_SIZE];
int solve(int n) {
int money[6] = {1, 5, 10, 20, 50, 100};
for (int i = 1;i <= n;i++) {
sum[i] = MAX_SIZE;
for (int j = 0;j < 6;j++) {
if (i - money[j] >= 0) {
sum[i] = MIN(sum[i - money[j]] + 1, sum[i]);
}
}
}
return sum[n];
}
int main() {
int n;
while (scanf("%d", &n) != EOF) {
memset(sum, 0, sizeof(sum));
printf("%d\n", solve(n));
}
return 0;
}
时间复杂度为(m为面值种类数),空间复杂度为
O(n)
O
(
n
)
通过这道例题,我们大致了解了,动态规划算法中非常重要的两个概念:状态和状态转移方程。那么这时候我们谈一谈贪心算法中遗留的这个问题:
假设纸币的面值只有三种:1、3、4。在同样的问题中,如果使用贪心策略,将是错误的。
具体:假设需要找6元的零钱,那么先拿出面额最大的一张四元,然后剩下2元只能用2张一元,于是使用了3张纸币。
然而实际上,我们只需要2张三元的纸币即可。
思考:在面额满足什么条件下才能使用本贪心策略?
对于这个问题,在CSDN搜索到是答案是:
对于一般的问题来说,现在还没研究出通用的模型,不少贪心可以用拟阵套,但不是全部。
至于硬币贪心的话,对于给定的硬币系统,给定一个总价,求最优解是NPC的,但是确认一个给定的硬币系统是否贪心解=最优解存在一个3次方的多项式算法(三四年前国内一个regional考过的论文题)
实际上,在一般情况下,对于找零钱的问题并不是用贪心策略解决的,主要原因在于用贪心策略解决这一类问题时,难以证明其正确性或者根本就无法证明。只是在某些特殊情况下,贪心策略在解决这种问题时有效,而这种问题的正规解法,即是上述递推型的动态规划解法。那么在面额满足什么条件下才能使用本贪心策略呢?一个很简单的想法,当零钱是翻倍序列(形如1,2,4,8,…, 2N 2 N )时,是可以使用贪心策略的。
回到例题1,我们从例题2中了解到了状态和状态转移方程这两个概念,那么在例题1中,解法4实际上是动态规划的策略,我们将问题定义为:
maxSum(i,j)
m
a
x
S
u
m
(
i
,
j
)
为从位置
(i,j)
(
i
,
j
)
处到底部的最大和,这也就是状态,而最终我们要得到的结果为
maxSum(1,1)
m
a
x
S
u
m
(
1
,
1
)
,并且状态转移方程为:
明白了这两个概念,那我们来看例题3。
例题3.最长上升子序列
题目描述:如果一个数列 ai a i 满足 a1<a2<⋅⋅⋅<aN a 1 < a 2 < ⋅ ⋅ ⋅ < a N 则这个数列被称作上升序列。
给定一个数列 a(a1,a2,⋅⋅⋅,aN) a ( a 1 , a 2 , ⋅ ⋅ ⋅ , a N ) 则任意一个数列 b(ai1,ai2,⋅⋅⋅,aik) b ( a i 1 , a i 2 , ⋅ ⋅ ⋅ , a i k ) 并且满足 (1≤i1<i2<⋅⋅⋅<ik≤N) ( 1 ≤ i 1 < i 2 < ⋅ ⋅ ⋅ < i k ≤ N ) .则b被称为a的子序列。
如果一个数列的子序列是上升序列,则这个序列称为原序列的上升子序列。
比如序列 (1,7,3,5,9,4,8) ( 1 , 7 , 3 , 5 , 9 , 4 , 8 ) 的上升子序列有 (1,7),(3,4,8) ( 1 , 7 ) , ( 3 , 4 , 8 ) 等. 它的最长上升子序列长度是 4 4 ,即.
输入格式:
第一行是一个整数
N(1≤N≤1000)
N
(
1
≤
N
≤
1000
)
表示给定数列的长度,第二行包括N个范围在0~10000的整数。
输出格式:
输出一个整数表示最长上升子序列的最大长度
SampleInput
7
1 7 3 5 9 4 8
SampleOutput
4
分析:
在有了例题1和例题2的经验之后,对于这道题,我们尝试着以数学符号方式重新定义问题以及定义这个问题的子问题:
给定一个数列,长度为 N N ,
设为:以数列中第 k k 项结尾的最长递增子序列的长度.
求中的最大值.
对于 d(N) d ( N ) 来讲, d(1)..d(k−1) d ( 1 ) . . d ( k − 1 ) 都是 d(k) d ( k ) 的子问题:因为以第 k k 项结尾的最长递增子序列(下称),包含着以第 1..k−1 1.. k − 1 中某项结尾的 LIS L I S 。
上述的新问题 d(k) d ( k ) 就是我们要找的状态,定义中的“ d(k) d ( k ) 为数列中第 k k 项结尾的的长度”,就叫做对状态的定义。
为了方便理解我们是如何找到状态转移方程的,于是我们对样例进行分析,如果我们要求的这N个数的序列是:
1 7 3 5 9 4 8
根据上面找到的状态,我们可以得到:
- 前1个数的LIS长度d(1)=1(序列:1)
- 前2个数的LIS长度d(2)=2(序列:1,7;7前面有个比它小的1,所以d(2)=d(1)+1)
- 前3个数的LIS长度d(3)=2(序列:1,3;3前面有个比它小的1,所以d(3)=d(1)+1)
- 前4个数的LIS长度d(4)=3(序列:1,3,5;5前面比它小的有2个数,所以 d(4)=max{d(1), d(3)}+1=3)
- 前5个数的LIS长度d(5)=4(序列:1,3,5,9;9前面比它小的有4个数,所以 d(5)=max{d(1), d(2), d(3), d(4)}+1=4)
- 前6个数的LIS长度d(6)=3(序列:1,3,4;4前面比它小的有2个数,所以 d(6)=max{d(1), d(3)}+1=3)
- 前7个数的LIS长度d(7)=4(序列:1,3,5,8;8前面比它小的有5个数,所以 d(7)=max{d(1), d(2), d(3), d(4), d(6)}+1=4)
分析到这,状态转移方程已经很明显了,如果我们已经求出了d(1)到d(i-1), 那么d(i)可以用下面的状态转移方程得到:
通俗解释即是:想要求d(i),就把i前面的各个子序列中,最后一个数小于A[i]的序列长度加1,然后取出最大的长度即为d(i)。 当然了,有可能i前面的各个子序列中最后一个数都大于A[i],那么d(i)=1, 即它自身成为一个长度为1的子序列。
完整代码如下:
#include <cstdio>
#include <cstring>
#define MAX(a, b) (a > b ? a : b)
const int MAX_SIZE = 1e3 + 5;
int a[MAX_SIZE], d[MAX_SIZE];
int solve(int n) {
for (int i = 1;i <= n;i++) {
d[i] = 1;
for (int j = 1;j < i;j++) {
if (a[i] > a[j]) {
d[i] = MAX(d[i], d[j] + 1);
}
}
}
int maxn = d[1];
for (int i = 2;i <= n;i++) {
maxn = MAX(maxn, d[i]);
}
return maxn;
}
int main() {
int n;
while (scanf("%d", &n) != EOF) {
memset(d, 0, sizeof(d));
for (int i = 1; i <= n;i++) {
scanf("%d", &a[i]);
}
printf("%d\n", solve(n));
}
return 0;
}
时间复杂度为
O(n2)
O
(
n
2
)
,空间复杂度为
O(n)
O
(
n
)
到了这里,动态规划基础也就介绍完毕了。那么这时候你可能还会有一些疑问?大致如下:
1.上述对状态的定义以及状态转移方程是唯一的吗?
(尚未截稿)