这几天一直在做leetcode上关于动态规划方面的题目,虽然大二下的算法设计课上较为详细的讲过动态规划,奈何遇到新颖的题目或者稍加背景的题目立刻就原形毕露不知题目所云了。动态规划算是较难的一个专题了,但只要找到递推关系其最终的代码又相当简便。现在把这几天做过的题目整理总结一下,毕竟只求做题数量不求掌握精髓最终也没法提升自己的能力的。
先从简单的题目入手吧,代表题目:64.最小路径和、120.三角形最小路径和、62.不同路径、63.不同路径II。
以64题和62题为例,我们先看64题的的题目描述:
给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
示例:
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。
题目中说到路径从左下角到右下角,言下之意即为从路径的一个节点到下一个节点,只能向下走或者向右走,可以看出这是一个非常明显的暗示了。如下图:
(i-1,j)
|
v
(i,j-1)-> (i, j)
我们想要到达下一个节点(i,j),那我们是选择从节点(i, j-1)走过来呢还是从节点(i-1,j)走过来呢?既然题目要求路径和最小,那么我们肯定选择上节点对应路径值最小的作为中间节点。于是可以写出如下的递推式:
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int h = grid.size();
int w = grid[0].size();
for (int i=0; ifor (int j=0; jif (i==0 && j==0)
continue;
if (i==0){
grid[0][j] += grid[0][j-1]; continue;
}
if (j==0){
grid[i][0] += grid[i-1][0]; continue;
}
grid[i][j] += min(grid[i-1][j], grid[i][j-1]);
}
}
return grid[h-1][w-1];
}
};
120题三角形最短路径和与该题类似,基本采用同样的方法。那么我们再来看看62题,62题的题目描述如下:
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
问总共有多少条不同的路径?
例如,上图是一个7 x 3 的网格。有多少可能的路径?
说明:m 和 n 的值均不超过 100。
示例 1:
输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右
和64题大同小异,只是这次需要求解的是多少种可能的路径,同样我们注意到走到节点(i,j)有两种走法,一种是通过(i-1, j)从左边走过来,一种是通过(i, j-1)从上面走下来。于是这便提示我们建立一个二维数组dp(m, n),用于存储到达节点(i, j)时的路径数量。那么可以写出如下的递推关系:
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> num(m, vector<int>(n, 0));
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (i == 0 || j == 0) {
num[i][j] = 1;
continue;
}
num[i][j] = num[i][j - 1] + num[i-1][j];
}
}
return num[m - 1][n - 1];
}
};
以上题目类型都以图网络作为背景,涉及到图网络除了采用图的相应算法外动态规划也是很好的解决方式,而以上题目动态规划的意图比较明显,也比较容易写出递推关系。下面题目在以上题目的基础上加深了难度,需要一定的技巧,代表题目:174.地下城游戏、741.摘樱桃。
我们先来看看174的题目描述:
一些恶魔抓住了公主(P)并将她关在了地下城的右下角。地下城是由 M x N 个房间组成的二维网格。我们英勇的骑士(K)最初被安置在左上角的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。
骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。
有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。
为了尽快到达公主,骑士决定每次只向右或向下移动一步。
编写一个函数来计算确保骑士能够拯救到公主所需的最低初始健康点数。
例如,考虑到如下布局的地下城,如果骑士遵循最佳路径 右 -> 右 -> 下 -> 下
,则骑士的初始健康点数至少为 7。
-2 (K) | -3 | 3 |
---|---|---|
-5 | -10 | 1 |
10 | 30 | -5 (P) |
说明:
说了一大堆,把没有用的话剔除,题目仍然是一道以图网络为背景的题目。即有一个图网络mxn其节点的权重有正有负。骑士K需要从(0, 0)节点走到(m-1, n-1)节点,只能选择向下或者向右走。骑士K初始时有一定的“生命值”,且每经过一个节点其“生命值”加上该节点的权重,并且在过程中骑士K的“生命值”必须为正,然后要我们求满足条件的最小“生命值”。
看上去比之前的题目确实复杂了不少,初步想法是建立一个二维数组dp(m, n)用于存储每个节点对应的最小生命值.我们还是看一个具体的过程来分析:
(i, j) <- (i+1,j)
^
|
(i,j+1)
还是三个相邻节点:(i+1,j)、(i, j+1)和(i, j)。整个过程可以看做是从节点(m-1,n-1)反着走到(0, 0)节点。为什么要反着想呢?因为题目要求是求出发时至少要多少生命值,即dp(0, 0) ,因此逆向走回去恰好可以求得(0, 0)点对应的生命值。那么至少现在可以写出如下递推式:
class Solution {
public:
int calculateMinimumHP(vector<vector<int>>& dungeon) {
int h = dungeon.size();
int w = dungeon[0].size();
vector<vector<int>> OPT(h, vector<int>(w, 0));
OPT[h-1][w-1] = dungeon[h-1][w-1] < 0 ? -dungeon[h-1][w-1]+1:1;
for (int i=w-2; i>=0;--i) {
OPT[h-1][i] = max(OPT[h-1][i+1]-dungeon[h-1][i], 1);
}
for (int i=h-2;i>=0;--i){
OPT[i][w-1] = max(OPT[i+1][w-1]-dungeon[i][w-1], 1);
}
for (int i=h-2; i>=0;--i){
for (int j=w-2; j>=0;--j){
int right = max(OPT[i+1][j]-dungeon[i][j], 1);
int down = max(OPT[i][j+1]-dungeon[i][j], 1);
OPT[i][j] = min(right, down);
}
}
return OPT[0][0];
}
};
以字符串为背景的动态规划题目就很多了,包括回文串、字串啊、子序列等等。这里简单总结一下近期做过的相关题目:91.解码方法、72.编辑距离、139.单词拆分、140.单词拆分II、514.自由之路、516.最长回文子序列、647.回文子串、5.最长回文字串。
先从回文串入手,首先需要注意字串和子序列是有区别的,字串必须连续,而子序列可以不连续。回文串是一个对称的字符串。知道一些基本概念后先来看第516题的题目描述:
给定一个字符串s
,找到其中最长的回文子序列。可以假设s
的最大长度为1000
。
示例 1:
输入:
"bbbab"
输出:
4
一个可能的最长回文子序列为 “bbbb”。
示例 2:
输入:
"cbbd"
输出:
2
一个可能的最长回文子序列为 “bb”。
碰到子序列即可以不连续的情况常常让人发难,觉得无从下手。题目要返回最长的回文子序列,经过前面几题的套路,我们可以想到建立一个二维数组dp其维度为MxM,其中M代表字符串的长度。那么dp(i, j)的含义为从字符串i到j的范围回文子序列的最大长度。我们举一个具体的例子:对于字符串bbacddccadd
可以知道:
dp(9, 10)=2, dp(8, 10)=2, dp(7, 10)=2, dp(6, 10)=2, dp(5, 10)=3, dp(4, 10) =5
dp(8, 9) =1, dp(7, 9)=1, dp(6, 9)=2, dp(5, 9)=4
… … … …
可以发现dp(i, j)的大小与dp(i+1, j)、dp(i, j-1)以及dp(i+1, j-1)有关系。而回文字符串的关键在于对称性,因此我们可以得出如下递推关系:
当s[i] == s[j]
时,有:
class Solution {
public:
int longestPalindromeSubseq(string s) {
if (s.empty())
return 0;
int len = s.length();
vector<vector<int>> OPT(len, vector<int>(len, 0));
for (int i=len-1; i>=0; --i){
for (int j=i; jif (j == i)
OPT[i][j] = 1;
else if (s[j] == s[i])
OPT[i][j] = OPT[i+1][j-1] + 2;
else
OPT[i][j] = max(OPT[i+1][j], OPT[i][j-1]);
}
}
return OPT[0][len-1];
}
};
接下来我们来看看647题回文子串,其题目描述如下:
给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被计为是不同的子串。
示例 1:
输入: "abc"
输出: 3
解释: 三个回文子串: "a", "b", "c".
示例 2:
输入: "aaa"
输出: 6
说明: 6个回文子串: "a", "a", "a", "aa", "aa", "aaa".
注意:
与上题不同之处在于不再是子序列而是字串,并且要求的不是最大长度而是字串的个数。那么首先我们需要建立一个二维数组dp,维度为MxM,M为字符串长度。那么dp(i,j)则代表字符串从i到j的回文字串个数。看上去好像蛮简单的样子,那么我们继续分析,用一个实例:
即dp(0, 6) = dp(1, 6) + dp(0, 5) - dp(1, 4)。这也可以非常直观的理解,即最后要减去公共部分重复计算的。那么我们可以得到如下递推式:
当 s[i]==s[j] s [ i ] == s [ j ] 并且 dp(i−1,j+1)==true d p ( i − 1 , j + 1 ) == t r u e 时:
class Solution {
public:
int countSubstrings(string s) {
if (s.empty())
return 0;
int len = s.length();
vector<vector<int>> OPT(len, vector<int>(len, 0));
vector<vector<bool>> record(len, vector<bool>(len, false));
for (int i=len-1; i>=0; --i){
for (int j=i; jif (j == i){
OPT[i][j] = 1;
record[i][j] = true;
}
else if (j == i+1){
OPT[i][j] = s[i] == s[j] ? 3 : 2;
record[i][j] = (s[i] == s[j]);
}
else{
OPT[i][j] = OPT[i+1][j] + OPT[i][j-1]-OPT[i+1][j-1];
if (s[i] == s[j] && record[i+1][j-1]){
OPT[i][j]++;
record[i][j] = true;
}
else
record[i][j] = false;
}
}
}
return OPT[0][len-1];
}
};