转载文章,原文章来源于算法设计与分析基础系列--动态规划(三)--硬币收集问题(欢迎关注微信公众号,会定期更新内容)
============================================================
前言
本文内容基于书籍"算法设计与分析基础"(Introduction to The Design and Analysis of Algorithms,作者Anany Levitin),主要学习和讨论其中的动态规划算法。之前已经讨论过的币值最大化和找零问题也是基于动态规划算法的,
可参考
和
"如果问题是由交叠的子问题构成的,我们就可以用动态规划技术来解决它。一般来说,这样的子问题出现在对给定问题求解的递推关系中,这个递推关系中包含了相同类型的更小子问题的解。动态规划法建议,与其对交叠的子问题一次又一次地求解,还不如对较小的子问题只求解一次并把结果记录在表中,这样就可以从表中得到原始问题的解。"
上面对动态规划思想进行了一个比较"理论"的解释和说明,可能初学者会觉得稍微有些晦涩难懂。不过没有关系,随着不断地练习相关的题目,自然而然会对动态规划思想逐步形成更加直观或者"直觉"上的认知和理解,到时候再回来看这里的"理论"描述,就会建立更加深刻的认识。
问题描述--硬币收集问题
在n*m格木板中放有一些硬币,每格的硬币数目最多为1个。在木板左上方的一个机器人需要收集尽可能多的硬币并把它们带到右下方的单元格。每一步,机器人可以从当前的位置向右移动一格或者向下移动一格。当机器人遇到一个有硬币的单元格时,就会将这枚硬币收集起来。设计一个算法找出机器人能收集到的最大硬币数目,并给出相应的路径。
解答
这道题是一个较为经典的动态规划思想的入门题目,也可能会在一些面试题中出现。在下面的讨论中,我们先只考虑如何计算最大硬币数目,而不考虑如何给出相应的路径。
有了之前的币值最大化问题和找零问题的基础后,本篇文章会适当减少一些细节,同时以该问题为契机,讨论一下同一个题目不同的"动态规划思考方式"。
对于这个题目中的n*m大小的木板,我们对行从上到下依次编号为1,2,...,n,对列从左到右依次编号为1,2,..,m,并用a[i][j]表示第i行第j列的格子内是否有硬币(1表示有一枚硬币,0表示没有硬币)。起始的时候我们在a[1][1]这里,最终要到达a[n][m]位置。想要使用动态规划算法,首先我们要定义"状态",对于这个问题,比较直观的一种状态定义方式就是,以当前所到达的位置来表示此时的状态,即我们用dp[i][j]表示,"当我们从a[1][1]开始移动,并在此时到达了a[i][j],能够收集到的硬币最大数目为dp[i][j]"。
现在我们要考虑不同的状态之间是如何转移的,这里我们有两种不同的思考方式。
方式1,从当前的dp[i][j]开始,下一步我们可以转移到哪个状态?根据题目要求,从每个格子出发我们可以向右或者向下移动,当然前提是不能离开木板的范围。所以如果当前我们在dp[i][j],那么下一步我们可以到达dp[i][j+1],也可以选择到达dp[i+1][j]。如果我们选择到达dp[i][j+1],那就意味着,dp[i][j+1]可能会取到一个更大值dp[i][j]+a[i][j+1],即我们在到达dp[i][j]时已经取到的最大值再加上a[i][j+1]处是否有一个硬币,表示为dp[i][j+1]=max(dp[i][j+1], dp[i][j] + a[i][j+1])。同理,dp[i+1][j]也可能会取到一个更大值dp[i][j]+a[i+1][j],即dp[i+1][j]=max(dp[i+1][j], dp[i][j] + a[i+1][j])。
我们会发现,每次dp[i][j]能够到达的下一个状态总是右边或者下边,我们可以对dp[i][j]按照"先从上到下遍历每一行,而每一行内从左到右遍历每一列"的方式来计算dp[i][j]的结果,代码如下
const int C = 100; // 这里给一个较大的数 保证大于n和m即可
int n,m; // 木板大小
int a[C][C]; // 木板的每个格子内是否有硬币 0表示无硬币 1表示有一个硬币
int dp[C][C]; // 同上述dp数组的定义
dp[1][1] = a[1][1]; // 初始时在[1][1]位置 dp[1][1]就是a[1][1]的取值
for(int i = 1; i <= n; i++){ // 先从小到大遍历每行
for(int j = 1; j <= m; j++){ // 行内从左到右遍历每一列
if(j + 1 <= m){ // 从当前位置向右移动 前提是不超过木板边界
dp[i][j + 1] = max(dp[i][j + 1], dp[i][j] + a[i][j + 1]);
}
if(i + 1 <= n){ // 从当前位置向下移动 前提是不超过木板边界
dp[i + 1][j] = max(dp[i + 1][j], dp[i][j] + a[i + 1][j]);
}
}
}
cout<<dp[n][m]<<endl; // 输出答案
方式2,方式1考虑的是从当前状态可以转移到后续的哪些状态,而方式2恰好相反,它考虑的是当前状态可以由哪些"前置状态"转移而来。
我们还是考虑dp[i][j],它会由哪些状态转移而来呢?根据题目要求,它只可能从上方的格子dp[i-1][j],或者是左边的格子dp[i][j-1]转移而来。即当前dp[i][j]的最大值可能是dp[i-1][j]+a[i][j],也可能是dp[i][j-1]+a[i][j]。那么为了求得dp[i][j]的取值,我们需要先确定其上方和左边的结果,这意味着我们依然可以按照"先从上到下遍历每一行,而每一行内从左到右遍历每一列"的顺序来求取dp[i][j]的取值。代码如下
const int C = 100; // 这里给一个较大的数 保证大于n和m即可
int n,m; // 木板大小
int a[C][C]; // 木板的每个格子内是否有硬币 0表示无硬币 1表示有一个硬币
int dp[C][C]; // 同上述dp数组的定义
for(int i = 1; i <= n; i++){ // 先从小到大遍历每行
for(int j = 1; j <= m; j++){ // 行内从左到右遍历每一列
if(j - 1 >= 1){ // 当前位置从左边移动而来 前提是不超过木板边界
dp[i][j] = max(dp[i][j], dp[i][j - 1] + a[i][j]);
}
if(i - 1 <= n){ // 当前位置从上方移动 前提是不超过木板边界
dp[i][j] = max(dp[i][j], dp[i - 1][j] + a[i][j]);
}
}
}
cout<<dp[n][m]<<endl; // 输出答案
从上面的两种方式我们可以发现,对于当前状态,我们可以考虑"它可以往哪些状态转移",以及,"哪些状态可以转移到它",这也对应了两种不同的代码写法。在遇到不同的问题的时候,可能两种方式处理起来都比较方便,也可能只是其中一种方式处理起来会更加方便,针对不同的问题要能够灵活应对。
总结
1,一个问题是否可以使用动态规划算法求解的关键点在于"如果问题是由交叠的子问题构成的,我们就可以用动态规划技术来解决它",这里需要慢慢体会。
2,动态规划问题在状态定义上是有些技巧的,好的定义可以让转移公式变得简洁明了,这个也只能通过不断地练习来获得更加深刻的理解。
3,对于当前状态的转移,主要有两种思考方式,我们可以考虑"它可以往哪些状态转移",以及,"哪些状态可以转移到它"。
欢迎大家多多转载并关注后续更新,如果有其他算法相关的问题也欢迎留言讨论~