算法之动态规划(DP)
1.什么是动态规划
动态规划类似于贪心,同样是求最优解,但是贪心是局部求最优解,动态规划则是体现在动态上,是将一个状态的统计结果传递到下一个状态从而取得全局最优解。当然,入门动态规划不必死磕概念和与贪心的区别,后面通过做题可以对动态规划有更深的理解
2.动态规划的基本思想
上面说了动态规划是将一个状态的统计结果传递到下一个状态,这很类似于递推的概念。事实上,动态规划中的最重要一步就是找出递推关系式,甚至在洛谷题单上,很多动态规划题目都被放在递归递推题单中(比如斐波那契数列,爬楼梯等,都是动态规划的入门题)。在从递归到递推 - 入门篇这篇博客中详细讲过斐波那契数列,本篇就不再赘述了,下面看一些较复杂的动态规划题目,从这些题目上感悟动态规划的思想。
3.动态规划经典题目
3.1.使用最小花费爬楼梯力扣746题
给你一个整数数组
cost
,其中cost[i]
是从楼梯第i
个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。你可以选择从下标为
0
或下标为1
的台阶开始爬楼梯。请你计算并返回达到楼梯顶部的最低花费。
示例 1:
输入:cost = [10,15,20] 输出:15 解释:你将从下标为 1 的台阶开始。 - 支付 15 ,向上爬两个台阶,到达楼梯顶部。 总花费为 15 。
示例 2:
输入:cost = [1,100,1,1,1,100,1,1,100,1] 输出:6 解释:你将从下标为 0 的台阶开始。 - 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。 - 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。 - 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。 - 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。 - 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。 - 支付 1 ,向上爬一个台阶,到达楼梯顶部。 总花费为 6 。
前面说过,动态规划题目的重点在于递推关系式的求解上,然而这道题没有像上面的斐波那契数列一样有着很明显很简单的规律,不过事实上,这道题和上面说过的爬楼梯题目思路大体不变,这道题只是多加了一个楼梯花费。我们可以选择一次爬一层或者两层,既然求最小费用,那么就选前面两层中往上爬花费最小的一层即可。有了初步想法,我们先看下面题解根据题解理解
int min(int a, int b){
return a > b ? b : a;
}
int minCostClimbingStairs(int* cost, int costSize) {
int dp[costSize + 1];
dp[0] = dp[1] = 0;
for (int i = 2; i <= costSize; i++) {
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
return dp[costSize];
}
这道题的递推关系式就是 dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
其中dp
数组记录的是走到该层需要花费的最小费用,cost
数组记录的是该层向上走需要花费的费用。
这道题中,还包含了处理动态规划两个很重要的元素,即确定dp
数组的含义(清楚数组下标和对应的数组值代表什么)和对dp
数组的初始化,这个一般根据题意初始即可,这道题因为可以选择从一楼或者零楼开始而这是没有费用的,因此将两者初始化为零
3.2.不同路径(力扣62题)
一个机器人位于一个
m x n
网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
示例 1:
输入:m = 3, n = 7 输出:28
示例 2:
输入:m = 3, n = 2 输出:3 解释: 从左上角开始,总共有 3 条路径可以到达右下角。 1. 向右 -> 向下 -> 向下 2. 向下 -> 向下 -> 向右 3. 向下 -> 向右 -> 向下
这道题的规律其实还是很好找的,第一行,第一列每个位置(除了初始位置)都只有一种走法,除此之外的每个位置都等于上一行对应走法数加上一列对应走法数,下面是代码实现
int uniquePaths(int m, int n) {
int i = 0, j = 0, arr[105][105] = {0};
for(i = 1; i <= m; i++){
arr[i][1] = 1;
}
for(i = 1; i <= n; i++){
arr[1][i] = 1;
}
for(i = 2; i <= m; i++){
for(j = 2; j <= n; j++){
arr[i][j] = arr[i - 1][j] + arr[i][j - 1];
}
}
return arr[m][n];
}
这道题显然很简单,下面我们看一道与这道题相似但是复杂很多的题
3.3过河卒洛谷P1002
题目描述
棋盘上 A 点有一个过河卒,需要走到目标 B 点。卒行走的规则:可以向下、或者向右。同时在棋盘上 C 点有一个对方的马,该马所在的点和所有跳跃一步可达的点称为对方马的控制点。因此称之为“马拦过河卒”。
棋盘用坐标表示,A 点(0,0)、B 点 (n,m),同样马的位置坐标是需要给出的。
现在要求你计算出卒从 A 点能够到达 B 点的路径的条数,假设马的位置是固定不动的,并不是卒走一步马走一步。
输入格式
一行四个正整数,分别表示 B* 点坐标和马的坐标。
输出格式
一个整数,表示所有的路径条数。
输入输出样例
输入 #1
6 6 3 3
输出 #1
6
这道题唯一和上面的不同的是马的存在,我们需要排除掉马及其控制点的存在。
马既然固定不动,那么我们只需要将马及其控制点全部标记为0即可。这道题思路简单但是代码的实现有很多细节问题需要处理,代码如下,不再过多介绍了
#include<stdio.h>
int main() {
long long int arr[24][24];
int m=0,n=0,x=0,y=0;
scanf("%d %d",&m,&n);
scanf("%d %d",&x,&y);
for (int i = 0; i <=m; i++) {
for (int j = 0; j <= n; j++) {
arr[i][j]=1;
}
}
arr[x][y]=0;
if(x-2>=0&&y-1>=0) arr[x-2][y-1]=0;
if(x-2>=0&&y+1<=n) arr[x-2][y+1]=0;
if(x-1>=0&&y-2>=0) arr[x-1][y-2]=0;
if(x-1>=0&&y+2<=n) arr[x-1][y+2]=0;
if(x+1<=m&&y-2>=0) arr[x+1][y-2]=0;
if(x+1<=m&&y+2<=n) arr[x+1][y+2]=0;
if(x+2<=m&&y-1>=0) arr[x+2][y-1]=0;
if(x+2<=m&&y+1<=n) arr[x+2][y+1]=0;
for (int i = 0; i <=m; i++) {
for (int j = 0; j <= n; j++) {
if(i==0&&j==0) continue;
if(arr[i][j]==0) continue;
if(i==0) arr[i][j]=arr[i][j-1];
else if(j==0) arr[i][j]=arr[i-1][j];
else arr[i][j]=arr[i-1][j]+arr[i][j-1];
}
}
printf("%lld",arr[m][n]);
return 0;
}
3.4.力扣343整数拆分
给定一个正整数
n
,将其拆分为k
个 正整数 的和(k >= 2
),并使这些整数的乘积最大化。返回 你可以获得的最大乘积
示例 1:
输入: n = 2 输出: 1 解释: 2 = 1 + 1, 1 × 1 = 1。
示例 2:
输入: n = 10 输出: 36 解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
这道题首先在于如何拆这个数,拆成几个数的和。思考一下,这道题有两种拆法,一种是拆出一个数 j,计算((i-j)*j )。另一种是拆成多个数依次相乘,可以表示为i*dp[i-j](dp[i-j]是多个数相乘得到的最大),然后我们再比较两种拆法的大小即可,这就是递推关系式的思路。确定了递推关系式,还应该注意初始化,这道题从dp[2](1 不能拆分乘正整数)开始算起,dp[2] = 1。代码如下
int max(int a, int b){
return a > b? a :b;
}
int integerBreak(int n) {
int i = 0, j = 0, curmax = 0, dp[100];
dp[2] = 1;
for(i = 2; i <= n; i++){
for(j = 1; j < i; j++){
curmax = max(curmax, max((i - j) * j, dp[i - j] * j));
}
dp[i] = curmax;
}
return dp[n];
}
3.5.力扣300最长递增子序列
子序列问题也是动态规划中很经典的题目,我们最后来了解一下这类题目
给你一个整数数组
nums
,找到其中最长严格递增子序列的长度。子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,
[3,6,2,7]
是数组[0,3,1,6,2,2,7]
的子序列示例 1:
输入:nums = [10,9,2,5,3,7,101,18] 输出:4 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
输入:nums = [0,1,0,3,2,3] 输出:4
示例 3:
输入:nums = [7,7,7,7,7,7,7] 输出:1
看完这道题可能会有暴力求解的冲动,但是这道题并不那么容易暴力出来,如果有想法可以尝试一下。这里主要看一下动态规划的具体做法
首先,这道题很有必要先明确dp
数组及下标的含义。dp
数组中下表应该对应nums
数组中第i个元素,对应的元素值代表以dp[i]
数组元素为结尾的最长递增序列的长度。对数组中每个元素,都由前向后依次用j遍历。再看递推关系式 dp[i] = Max(dp[j] + 1, dp[i]);
,因为一个数列中一个数字可能会多次出现,因此我们需要比较一下以该数字为结尾的最长递增数列长度和遍历到的以nums[j]
为结尾的最长递增序列长度。另外,此题的最长递增序列长度并不一定是dp[numsSize - 1]
,需要比较整个dp
数组
int Max(int a, int b){
return a > b ? a : b;
}
int lengthOfLIS(int* nums, int numsSize) {
int i = 0, j = 0, max = 1, dp[numsSize];
for(i = 0; i < numsSize ; i++){
dp[i] = 1;
}
for(i = 0; i < numsSize; i++){
for(j = 0; j < i; j++){
if(nums[i] > nums[j]){
dp[i] = Max(dp[j] + 1, dp[i]);
}
if(max < dp[i]){
max = dp[i];
}
}
}
return max;
}
4.总结
动态规划概括来说就是求最优解的问题,对这类题有很明确的步骤-定义dp
数组 -> 推导递推关系式 。其核心还是搞清楚状态转移,明白如何推导递推关系式。