LeetCode算法练习——动态规划 DP

前面几周完成了DFS、BFS的章节,一共大约40题左右,今天开始练习动态规划。动态规划是一种将原问题拆分成子问题,对子问题进行求解,最终解决原文题的思想。动态规划适用于子问题不是独立的情况,也就是各子问题包含公共的子子问题。
DP的关键在于寻找状态转移方程,即如何从前一状态转移到后一状态,也可以理解为子问题的递推公式。

动态规划设计的两种方法:
自顶向下(又称记忆化搜索、备忘录):基本上对应着递归函数实现,从大范围开始计算,要注意不断保存中间结果,避免重复计算
自底向上(递推):从小范围递推计算到大范围

其实前几节搜索的问题很多也可以用DP的方式去求解。

给个目录:
LeetCode63 不同路径 II
LeetCode64 最小路径和
LeetCode120 三角形最小路径和
LeetCode91 解码方法
LeetCode139 单词拆分
LeetCode338 比特位计数
LeetCode357 计算各个位数不同的数字个数
LeetCode375 猜数字大小 II
LeetCode516 最长回文子序列
LeetCode647 回文子串

LeetCode63 不同路径 II

题目

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
说明:m 和 n 的值均不超过 100。

示例 1:

1
2
3
4
5
6
7
8
9
10
11
12
输入:
[
[0,0,0],
[0,1,0],
[0,0,0]
]
输出: 2
解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右

C++代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size(); //地图高度
int n = obstacleGrid[0].size(); ////地图宽度
vector<vector<int>> dp(m+1,vector<int>(n+1,0)); //初始化一个dp数组
bool row_flag = true; //用来判断第一行是否有1
bool col_flag = true; //用来判断第一列是否有1
for(int i=0;i<n;i++){ //将第一行第一个障碍之前的所有点 标记为1 表示有一种路径可以到达 第一个障碍之后的所有点标记为0 表示有0种路径可达
if(obstacleGrid[0][i]==1) row_flag = false;
if(row_flag) dp[0][i]=1;
else dp[0][i]=0;
}
for(int i=0;i<m;i++){ //将第一列第一个障碍之前的所有点 标记为1 表示有一种路径可以到达 第一个障碍之后的所有点标记为0 表示有0种路径可达
if(obstacleGrid[i][0]==1) col_flag = false;
if(col_flag) dp[i][0]=1;
else dp[i][0]=0;
}
for(int i=1;i<m;i++){
for(int j=1;j<n;j++){ //从[1][1]开始计算到达每一点的路径数,如果该点不是障碍则计算,状态转移方程为dp[i][j] = max(dp[i][j],dp[i-1][j]+dp[i][j-1])
if(obstacleGrid[i][j]!=1) dp[i][j] = max(dp[i][j],dp[i-1][j]+dp[i][j-1]);
}
}
return dp[m-1][n-1]; //返回右下角的值
}
};

LeetCode64 最小路径和

题目

给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

示例:

1
2
3
4
5
6
7
8
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
//dp数组表示从左上角到当前点的最小路径和
int m = grid.size(); //矩阵的高度
int n = grid[0].size(); //矩阵的宽度
vector<vector<int>> dp(m+1,vector<int>(n+1,0)); //初始化一个dp数组
dp[0][0] = grid[0][0]; //第一个点的最小路径就是自己
for(int i=1;i<n;i++){
dp[0][i]=dp[0][i-1] + grid[0][i]; //初始化第一行 dp[0][i]=之前的路径和+当前路径
}
for(int i=1;i<m;i++){
dp[i][0] = dp[i-1][0] + grid[i][0]; //初始化第一列 dp[i][0]=之前路径和+当前路径
}
for(int i=1;i<m;i++){
for(int j=1;j<n;j++){
dp[i][j] = min(dp[i-1][j],dp[i][j-1])+grid[i][j]; //状态转移方程 每一点最小路径等与左点 上点中的较小值+当前点的值
}
}
return dp[m-1][n-1]; //返回右下角点
}
};

体会

简单的DP题。dp[i][j]表示从左上角到[i]j]的最小路径和,首先初始化第一行与第一列、第一行、第一列每一个最小路径都等于前一个点的最小路径加上当前路径,从[1][1]点开始后,状态转移方程为dp[i][j] = min(dp[i-1][j],dp[i][j-1])+grid[i][j],每一点最小路径等与 左点 上点 中的较小值+当前点的值。最后返回右下角的点。

LeetCode120 三角形最小路径和

题目

给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。

例如,给定三角形:

1
2
3
4
5
6
[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]

自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。

说明:

如果你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题,那么你的算法会很加分。

C++代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int minimumTotal(vector<vector<int>> triangle) {
int width = triangle[triangle.size()-1].size(); //三角形的最大宽度
int depth = triangle.size(); //三角形最大高度
vector<vector<int>> dp(triangle.size(),vector<int>(triangle[triangle.size()-1].size(),0)); //初始化一个dp数组
//dp[i][j]表示从底部开始向上计算 第i行j列的最小路径和
for(int i=0;i<width;i++){
dp[depth-1][i] = triangle[depth-1][i]; //初始化最后一行的值 最后一行的最小路路径就是三角形的路径
}
for(int i=depth-2;i>=0;i--){ //从倒数第二行向上开始计算
for(int j=0;j<triangle[i].size();j++){//遍历一行中的所有数据
dp[i][j] = triangle[i][j] + min(dp[i+1][j],dp[i+1][j+1]);//状态转移方程 选出底下一行中较小的数字 将其与当前三角形内的值相加
}
}
return dp[0][0]; //最终的结果
}

体会

一道基础的DP题,从底向上进行计算,dp[i][j]表示第i行j列的最小路径和。从倒数第二行向上开始循环,状态转移方程为dp[i][j] = triangle[i][j] + min(dp[i+1][j],dp[i+1][j+1]);即选出底下一行中较小的数字,将其与当前三角形内的值相加。最终dp[0][0]为最终的结果。

LeetCode91 解码方法

题目

一条包含字母 A-Z 的消息通过以下方式进行了编码:

1
2
3
4
'A' -> 1
'B' -> 2
...
'Z' -> 26

给定一个只包含数字的非空字符串,请计算解码方法的总数。

示例 1:

1
2
3
输入: "12"
输出: 2
解释: 它可以解码为 "AB"(1 2)或者 "L"(12)。

示例 2:

1
2
3
输入: "226"
输出: 3
解释: 它可以解码为 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6) 。

C++代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int numDecodings(string s) {
vector<int> dp(s.size()+1,0);
dp[0]=1;
//dp[i]表示前i位的解码方式为dp[i]种
for(int i=1;i<dp.size();i++){
if(s[i-1]=='0') dp[i-1] = 0; //0 不能单独解码,dp[i-1]赋值为0
if(s[i-2]=='1' || (s[i-2]=='2'&&s[i-1]<'7')) dp[i] = dp[i-1]+dp[i-2]; //如果十位为1或者十位为2 个位<7 ,证明这个数字可以按照两位数进行分解
else dp[i] = dp[i-1]; // 如果不满足分解情况,直接dp[i] = dp[i-1]
}
return dp[s.size()]; //返回dp[size]为最终的结果
}
};

体会

斐波纳切问题,建立一个dp数组,dp[i]表示前i为的解码方式为dp[i]种。初值赋值为1,表示至少都有一种解码方式,即逐个分割。之后对后面的数字进行循环,如果遇到0,0不能单独解码,将dp[i-1]设置为0;如果遇到1开头,或者2开头且后一位小于7的数字,证明其可以看成一个两位数,则多一种解码方式,dp[i] = dp[i-1]+1; 最终输出dp[s.size()]表示最终的结果。

LeetCode139 单词拆分

题目

给定一个非空字符串 s 和一个包含非空单词列表的字典 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。

说明:

拆分时可以重复使用字典中的单词。
你可以假设字典中没有重复的单词。
示例 1:

1
2
3
输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以被拆分成 "leet code"。

示例 2:

1
2
3
4
输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以被拆分成 "apple pen apple"。
注意你可以重复使用字典中的单词。

示例 3:

1
2
输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

C++代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
if(s.size()<0 || wordDict.empty()) return false; //如果字符串长度小于0,或者字典为空,则返回空值。
unordered_set<string> dict; //将wordDict转换成一个unordered_set,方便快速查询
for(auto key:wordDict) dict.insert(key); //将wordDict转化为dict
vector<bool> dp(s.size()+1,false); //创建一个dp数组 dp[i]表示前i个字符是否可以被拆分
dp[0] = true; //初始值为true
for(int i=1;i<=s.size();i++){ //前i个数字 字符串的终点
for(int j=0;j<=i;j++){ //字符串的起点
string new_str = s.substr(j,i-j); //i-j表示字符串的长度 j表示字符串的起点
if(dp[j] && dict.count(new_str)) { //dp[j]表示前j个字符是否可以被划分(相当于起点前的字符串被划分) new_str是新的字符串 如果前面的字符串可以被划分且new_str可以被划分,则字符串前i个字符可以被划分。
dp[i] = true;
break;
}
}
}
return dp[s.size()];
}
};

体会

动态规划问题,判断一个字符串是否可以划分,只需要将其不断拆分为小的字符串,判断其是否可以划分即可。dp[i]表示前i个字符是否可以划分。i从1到size进行循环,j从0到i进行循环,i可以看成是子串的终点,j可以看成是子串的起点,如果dp[j]为true,表示前j个字符是可以划分的,如果当前的new_str可以划分,那么证明前i个字符是可以划分的,依次类推,循环整个字符串。

LeetCode338 比特位计数

题目

给定一个非负整数 num。对于 0 ≤ i ≤ num 范围中的每个数字 i ,计算其二进制数中的 1 的数目并将它们作为数组返回。

示例 1:

1
2
输入: 2
输出: [0,1,1]

示例 2:

1
2
输入: 5
输出: [0,1,1,2,1,2]

进阶:

给出时间复杂度为O(n*sizeof(integer))的解答非常容易。但你可以在线性时间O(n)内用一趟扫描做到吗?
要求算法的空间复杂度为O(n)。
你能进一步完善解法吗?要求在C++或任何其他语言中不使用任何内置函数(如 C++ 中的 __builtin_popcount)来执行此操作。

C++代码

1
2
3
4
5
6
7
8
9
class Solution {
public:
vector<int> countBits(int num) {
vector<int> dp(num+1,0);
for(int i=1;i<=num;i++)
dp[i] = dp[i>>1]+(i&1);//1001 可以看成是 100中1的个数 + 最后一位是否为1
return dp;
}
};

体会

这个题的关键就在于状态转移方程。1001中1的个数可以看作是100中1的个数加上最后一位是否为1,所以状态状态转移方程为dp[i] = dp[i>>1]+(i&1),若推导过程中用十进制去思考,很难观察到该状态转移方程。

LeetCode357 计算各个位数不同的数字个数

题目

给定一个非负整数 n,计算各位数字都不同的数字 x 的个数,其中 0 ≤ x < 10n 。

示例:

1
2
3
输入: 2
输出: 91
解释: 答案应为除去 11,22,33,44,55,66,77,88,99 外,在 [0,100) 区间内的所有数字。

C++代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int countNumbersWithUniqueDigits(int n) {
if(n==0) return 0; //0 1 2 这三种情况直接输出
if(n==1) return 10;
if(n==2) return 91;
if(n>2){
int sum = 91;//两位数中的各个位不同的数字有91个
for(int j=3;j<=n;j++){
int bit = 9; //第二位有9种情况
int small_sum = 9; //第一位有9种情况
for(int i=0;i<j-1;i++){
small_sum = small_sum*bit; //各个位的情况相乘
bit--; //从第二位开始往后每位可选择的数字少一个
}
sum = sum+small_sum;//统计所有数据
}
return sum;
}
}

体会

组合排列问题。当n=2时:十位有9种选择,个位有9种选择;当n=3时,百位有9种选择,十位有9种选择,个位有8种选择;当n=4时,千位有9种选择,百位有9种选择,十位有8种选择,个位有7种选择。
所以:
n=2 9x9
n=3 9x9x8
n=4 9x9x8x7
以此类推,n在10以上后,必定有重复。

LeetCode375 猜数字大小 II

题目

我们正在玩一个猜数游戏,游戏规则如下:

我从 1 到 n 之间选择一个数字,你来猜我选了哪个数字。

每次你猜错了,我都会告诉你,我选的数字比你的大了或者小了。

然而,当你猜了数字 x 并且猜错了的时候,你需要支付金额为 x 的现金。直到你猜到我选的数字,你才算赢得了这个游戏。

示例:

1
2
3
4
5
6
7
8
9
n = 10, 我选择了8.
第一轮: 你猜我选择的数字是5,我会告诉你,我的数字更大一些,然后你需要支付5块。
第二轮: 你猜是7,我告诉你,我的数字更大一些,你支付7块。
第三轮: 你猜是9,我告诉你,我的数字更小一些,你支付9块。
游戏结束。8 就是我选的数字。
你最终要支付 5 + 7 + 9 = 21 块钱。

给定 n ≥ 1,计算你至少需要拥有多少现金才能确保你能赢得这个游戏。

C++代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int getMoneyAmount(int n) {
vector<vector<int>> dp(n+1,vector<int>(n+1,0));
//极小极大问题
if(n<=2) return n-1;
for(int i=2;i<=n;i++){ //i表示终点
for(int j=i-1;j>0;j--){ //j表示起点
int global = 999; //求最后的极小值 先初始化一个极大值
int local = 0; //需要找到每一个子序列的极大值
for(int k=j+1;k<i;k++){
local = k + max(dp[k+1][i],dp[j][k-1]); //状态转移方程
global = min(local,global); //最后取i j之间的最小值
}
dp[j][i] = j+1==i?j:global;
}
}
return dp[1][n];//整个序列
}
};

体会

这个题目的技巧性还是比较高的,状态转移方法比较难以寻找。
那么我们需要建立一个二维的dp数组,其中dp[i][j]表示从数字i到j之间猜中任意一个数字最少需要花费的钱数,那么我们需要遍历每一段区间[j, i],维护一个全局最小值global_min变量,然后遍历该区间中的每一个数字,计算局部最大值local_max = k + max(dp[j][k - 1], dp[k + 1][i]),这个正好是将该区间在每一个位置都分为两段,然后取当前位置的花费加上左右两段中较大的花费之和为局部最大值,为啥要取两者之间的较大值呢,因为我们要cover所有的情况,就得取最坏的情况。然后更新全局最小值,最后在更新dp[j][i]的时候看j和i是否是相邻的,相邻的话赋为i,否则赋为global_min。这里为啥又要取较小值呢,因为dp数组是求的[j, i]范围中的最低cost,比如只有两个数字1和2,那么肯定是猜1的cost低。

如果只有一个数字,那么我们不用猜,cost为0。
如果有两个数字,比如1和2,我们猜1,即使不对,我们cost也比猜2要低。
如果有三个数字1,2,3,那么我们就先猜2,根据对方的反馈,就可以确定正确的数字,所以我们的cost最低为2。
如果有四个数字1,2,3,4,那么情况就有点复杂了,那么我们的策略是用k来遍历所有的数字,然后再根据k分成的左右两个区间,取其中的较大cost加上k。
当k为1时,左区间为空,所以cost为0,而右区间2,3,4,根据之前的分析应该取3,所以整个cost就是1+3=4。
当k为2时,左区间为1,cost为0,右区间为3,4,cost为3,整个cost就是2+3=5。
当k为3时,左区间为1,2,cost为1,右区间为4,cost为0,整个cost就是3+1=4。
当k为4时,左区间1,2,3,cost为2,右区间为空,cost为0,整个cost就是4+2=6。
引自:https://blog.csdn.net/xuxuxuqian1/article/details/81636415

LeetCode516 最长回文子序列

题目

给定一个字符串s,找到其中最长的回文子序列。可以假设s的最大长度为1000。

示例 1:

1
2
3
4
5
6
7
输入:
"bbbab"
输出:
4
一个可能的最长回文子序列为 "bbbb"。

示例 2:

1
2
3
4
5
6
7
输入:
"cbbd"
输出:
2
一个可能的最长回文子序列为 "bb"。

C++代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
public:
int longestPalindromeSubseq(string s) {
string t = s;
reverse(t.begin(),t.end()); //新建一个字符串 取反向 寻找公共子序列 就是最长回文串
return lcs(t,s);
}
int lcs(string src,string dst){
int n = src.size();
vector<vector<int>> dp(n+1,vector<int>(n+1,0)); //新建一个dp数组
//dp数组初始化 第一行 第一列初始化为0
for(int i=0;i<=n;i++) {
dp[0][i] = 0;
dp[i][0] = 0;
}
for(int i=1;i<=n;i++){ //从第二个元素开始遍历
for(int j=1;j<=n;j++){
if(src[i-1]==dst[j-1]) dp[i][j] = dp[i-1][j-1]+1; //状态转移方程
else {
dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
}
}
}
return dp[n][n];
}
};

体会

判断最长回文子串,只需要将一个字符串反转后与原字符串求LCS,就是最长回文子串。
LCS的状态转移方程比较简单。
0 if i=0 or j=0
c[i,j] = c[i-1,j-1]+1 if i,j>0 and xi = yj
max(c[i,j-1],c[i-1,j]) if i,j>0 and xi != yj
记住就好

LeetCode647 回文子串

题目

给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。

具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被计为是不同的子串。

示例 1:

1
2
3
输入: "abc"
输出: 3
解释: 三个回文子串: "a", "b", "c".

示例 2:

1
2
3
输入: "aaa"
输出: 6
说明: 6个回文子串: "a", "a", "a", "aa", "aa", "aaa".

注意:

输入的字符串长度不会超过1000。

C++代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int help(string str,int left,int right){
int count = 0;
while(str[left]==str[right] && left>=0 && right<=str.size()-1){ //如果str[left]==str[right] 且没有越界的情况下 向左右伸展 并记数
count++; //记数
left--; //向左伸展
right++; //向右伸展
}
return count;
}
int countSubstrings(string s) {
int sum = 0; //用来记数
for(int i=0;i<s.size();i++){
sum+= help(s,i,i); //长度为奇数的回文数
sum+= help(s,i,i+1); //长度为偶数的回文数
}
return sum;
}
};

体会

简单题,回文数有两种情况,一种是长度为奇数的回文数,一种是长度为偶数的回文数,找到中间值,向两边伸展同时进行判断就可以。