动态规划
动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。
所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。
对于动态规划问题,四步解决:
-
确定dp数组(dp table)以及下标的含义
-
确定递推公式
-
dp数组如何初始化
-
确定遍历顺序
最后可以举例推导dp数组,然后可以打印dp数组对比一下
最简单和经典的案例:斐波那契数列(兔子数列)
分析过程
1.确定dp数组以及下标的含义
dp[i]的定义为:第i个数的斐波那契数值是dp[i]
2.确定递推公式
状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]
3.dp数组如何初始化
dp[0] = 0;
dp[1] = 1;
4.确定遍历顺序
从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的
一维dp:
//和斐波那契数列一样
class Solution {
public:
int climbStairs(int n) {
if(n<=2) return n;
vector<int> v(n+1);
v[0]=0;
v[1]=1;
v[2]=2;
for (int i =3 ; i < n+1; ++i) {
v[i]=v[i-1]+v[i-2];
}
return v[n];
}
};
746. 使用最小花费爬楼梯(最小消耗版本爬楼梯)
/*
1.dp[i]:是到达每一层的花费
2.确定递推公式:dp[i]=min(dp[i-1],dp[i-2])+cost[i]
3.dp的初始化:dp[0]=cost[0],dp[1]=cost[1]
4.确定遍历顺序:dp顺序从左到右
*注意结果从后两位中挑选
*/
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int dp0=cost[0];
int dp1=cost[1];
int tem=0;
int sum=dp0+dp1;
for (int i = 2; i <cost.size(); ++i) {
tem=min(dp0,dp1)+cost[i];
dp0=dp1;
dp1=tem;
}
return min(dp0,dp1);
}
};
343. 整数拆分
/*
1.dp[i]:分拆数字i,可以得到的最大乘积为dp[i]。
2.确定递推公式:dp[i]最大乘积是怎么得到的呢?
其实可以从1遍历j,然后有两种渠道得到dp[i].
一个是j * (i - j) 直接相乘
一个是j * dp[i - j],相当于是拆分(i - j)
所以 dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j});
3.dp的初始化:为方便计算,dp[0] dp[1] 就直接给0。初始化dp[2] = 1。
4.确定遍历顺序
dp[i] 是依靠 dp[i - j]的状态(因为i-j<i),所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]。
*/
class Solution {
public:
int integerBreak(int n) {
vector<int> dp(59,0);
dp[2]=1;
for (int i = 3; i <=n; ++i) {
for (int j = 1; j <i ; ++j) {
dp[i]=max(dp[i],max(j*(i-j),j*dp[i-j]));
}
}
return dp[n];
}
};
96. 不同的二叉搜索树
/*
1.dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]。
2.确定递推公式:dp[i] += dp[j-1] * dp[i-j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量
3.dp初始化:初始化dp[0]就可以了。
从定义上来讲,空节点也是一颗二叉树,也是一颗二叉搜索树,这是可以说得通的。
从递归公式上来讲,dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量] 中以j为头结点左子树节点数量为0,也需要dp[以j为头结点左子树节点数量] = 1, 否则乘法的结果就都变成0了。
4.确定遍历顺序:
顺序遍历。因为j <=i,节点数为i的状态是依靠 i之前节点数的状态。
参考:https://leetcode-cn.com/problems/unique-binary-search-trees/solution/96-bu-tong-de-er-cha-sou-suo-shu-dong-ta-vn6x/
*/
class Solution {
public:
int numTrees(int n) {
vector<int> dp(n+1,0);
dp[0]=1;
for (int i = 1; i <n+1; ++i) {
for (int j = 1; j <=i ; ++j) {
dp[i]+=dp[j-1]*dp[i-j];
}
}
return dp[n];
}
};
二维dp:
62. 不同路径
/*
1.dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。
2.递推公式:想要求dp[i][j],只能有两个方向来推导出来,即dp[i - 1][j] 和 dp[i][j - 1]。
dp[i][j] = dp[i - 1][j] + dp[i][j - 1],
3.dp数组的初始化:dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。
4.确定遍历顺序:
这里要看一下递归公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1],dp[i][j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。
*/
class Solution {
public:
int uniquePaths(int m, int n) {//二维的更好理解
vector<vector<int>> dp(m, vector<int>(n, 0));
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
int uniquePaths2(int m, int n) {//一维数组用来dp,优化空间复杂度
vector<int> dp(n);
for (int i = 0; i < n; i++) dp[i] = 1;//第一行初始化
for (int j = 1; j < m; j++) {//从第二行开始
for (int i = 1; i < n; i++) {//上一行的dp数组拿下来直接往右迭代加
dp[i] += dp[i - 1];//更新
}
}
return dp[n - 1];
}
};
63. 不同路径 II
/*
1.dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。
2.递推公式:dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。
但需要注意一点,因为有了障碍,(i, j)如果就是障碍的话应该给0,表示它无法为后面或者下面的路径做贡献。
3.dp数组初始化
vector<vector<int>> dp(m, vector<int>(n, 0));//初始化为0
for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;
4.确定遍历顺序:从左到右,从上到下
*/
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m=obstacleGrid.size();
int n=obstacleGrid[0].size();
vector<vector<int>>dp(m,vector<int>(n,0));
for (int i = 0; i < m&& obstacleGrid[i][0]==0; ++i) dp[i][0]=1;
for (int i = 0; i < n&& obstacleGrid[0][i]==0; ++i) dp[0][i]=1;
for (int i = 1; i <m; ++i) {
for (int j = 1; j < n; ++j) {
if(obstacleGrid[i][j]==1) dp[i][j]=0;
else dp[i][j]=dp[i-1][j]+dp[i][j-1];
}
}
return dp[m-1][n-1];
}
};
背包问题模板:
void test_2_wei_bag_problem1() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 二维数组
vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
// 初始化
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = value[0];
}
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
cout << dp[weight.size() - 1][bagWeight] << endl;
}
int main() {
test_2_wei_bag_problem1();
}
********************************************************************************************
//一维更优且简介,复杂度也降低
void test_1_wei_bag_problem() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 初始化
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量,要逆序来,防止前面数组被覆盖
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_1_wei_bag_problem();
}
例题
416. 分割等和子集
class Solution {
public:
bool canPartition(vector<int>& nums) {
vector<int> dp(10001,0);//题目限制 最大的一半
int sum=0;
for (int i = 0; i < nums.size(); ++i) {
sum+=nums[i];
}
if(sum&1) return false;//奇数直接return false
for (int i = 0; i < nums.size(); ++i) {//遍历物品(weight)
for (int j = sum/2; j >=nums[i] ; --j) {//遍历背包大小(bagsize)遍历方向从后向前
dp[j]=max(dp[j],dp[j-nums[i]]+nums[i]);//weight[i] 和value[i]都是num[i]
}
}
if(dp[sum/2]==sum/2) return true;
return false;
}
}
1049. 最后一块石头的重量 II
/*
本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了。
本题物品的weight[i]为store[i],value[i]也为store[i]。
*/
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
vector<int> dp(15001, 0);
int sum = 0;
for (int i = 0; i < stones.size(); i++) sum += stones[i];
int target = sum / 2;
for (int i = 0; i < stones.size(); i++) { // 遍历物品
for (int j = target; j >= stones[i]; j--) { // 遍历背包
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - dp[target] - dp[target];
}
};
int count;
public int reversePairs(int[] nums) {
this.count = 0;
merge(nums, 0, nums.length - 1);
return count;
}
public void merge(int[] nums, int left, int right) {
int mid = left + ((right - left) >> 1);
if (left < right) {
merge(nums, left, mid);
merge(nums, mid + 1, right);
mergeSort(nums, left, mid, right);
}
}
public void mergeSort(int[] nums, int left, int mid, int right) {
int[] temparr = new int[right - left + 1];
int index = 0;
int temp1 = left, temp2 = mid + 1;
while (temp1 <= mid && temp2 <= right) {
if (nums[temp1] <= nums[temp2]) {
temparr[index++] = nums[temp1++];
} else {
//用来统计逆序对的个数
count += (mid - temp1 + 1);
temparr[index++] = nums[temp2++];
}
}
//把左边剩余的数移入数组
while (temp1 <= mid) {
temparr[index++] = nums[temp1++];
}
//把右边剩余的数移入数组
while (temp2 <= right) {
temparr[index++] = nums[temp2++];
}
//把新数组中的数覆盖nums数组
for (int k = 0; k < temparr.length; k++) {
nums[k + left] = temparr[k];
}
}
分类解题模板
背包问题大体的解题模板是两层循环,分别遍历物品nums和背包容量target,然后写转移方程,
根据背包的分类我们确定物品和容量遍历的先后顺序,根据问题的分类我们确定状态转移方程的写法
首先是背包分类的模板:
1、0/1背包:外循环nums,内循环target,target倒序且target>=nums[i];
2、完全背包:外循环nums,内循环target,target正序且target>=nums[i];
3、组合背包:外循环target,内循环nums,target正序且target>=nums[i];
4、分组背包:这个比较特殊,需要三重循环:外循环背包bags,内部两层循环根据题目的要求转化为1,2,3三种背包类型的模板
然后是问题分类的模板:
1、最值问题: dp[i] = max/min(dp[i], dp[i-nums]+1)或dp[i] = max/min(dp[i], dp[i-num]+nums);
2、存在问题(bool):dp[i]=dp[i]||dp[i-num];
3、组合问题:dp[i]+=dp[i-num];
动态规划应该如何debug
找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的!
打家劫舍
未完待续…