动态规划(待完善)
动规五部曲分别为:
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式(状态转移公式)
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组、
动态规划的核心就是递归+剪枝(存储键值,下次不再访问,用空间换时间)
找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的!
这道题目我举例推导状态转移公式了么?
我打印dp数组的日志了么?
打印出来了dp数组和我想的一样么?
或者更清晰的知道自己究竟是哪一点不明白,是状态转移不明白,还是实现代码不知道该怎么写,还是不理解遍历dp数组的顺序。
动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。
所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,例如:有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
动态规划中dp[j]是由dp[j-weight[i]]推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])。
但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。
所以贪心解决不了动态规划的问题。
动态规划的解题步骤:(1)确定dp数组以及下标含义 (2)确定递推公式 (3)dp数组如何初始化 (4)确定遍历顺序 (5)例举推导dp数组。
【基础题目】
【509】斐波那契数列
解题步骤:
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)确定遍历顺序
从前向后遍历
5)例举推导dp数组。
按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:
0 1 1 2 3 5 8 13 21 34 55
如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的。
//动态规划基础问题
/*递归的方法 :时间复杂度o(n^2) 空间复杂度o(1)*/
class Solution {
public:
int fib(int n) {
if(n<2)return n;
return fib(n-1)+fib(n-2);
}
};
/*动态规划的方法 时间复杂度o(n) 空间复杂度o(n)(经典做法)*/
class Solution {
public:
int fib(int n) {
if(n<2)return n;
vector<int> dp(n+1);
dp[0] = 0;
dp[1] = 1;
for(int i = 2;i<n+1;i++){
dp[i] = dp[i-1]+dp[i-2];
}
return dp[n];
}
};
/*动态规划简写 时间复杂度o(n) 空间复杂度o(1)*/
class Solution {
public:
int fib(int n) {
if(n<2)return n;
vector<int> dp(2);
dp[0] = 0;
dp[1] = 1;
for(int i = 2;i<n+1;i++){
int sum = dp[0]+dp[1];
dp[0] = dp[1];
dp[1] = sum;
}
return dp[1];
}
};
【70】爬楼梯
确定递归数列:找规律 f(n) = f(n-1)+f(n-2)
确定终值f(1) = 1 f(0) = 0
存储节点:定义数组存储节点
最标准的做法,要是还要优化空间复杂度就考虑上面的做法
class Solution {
public:
int climbStairs(int n) {
if(n<2)return n;//(f(1)= 1,f(2) =2)
vector<int> f(n+1);
f[1] =1;
f[2] =2;
for(int i =3;i<n+1;i++){
f[i] =f[i-1]+f[i-2];
}
return f[n];
}
};
【118】杨辉三角
注意申请数组具体那一行
注意改变数组的长度的函数resize(为了防止0出现)
class Solution {
public:
vector<vector<int>> generate(int numRows) {
vector<vector<int>> ret(numRows);
for (int i = 0; i < numRows; ++i) {
ret[i].resize(i + 1);
ret[i][0] = ret[i][i] = 1;
for (int j = 1; j < i; ++j) {
ret[i][j] = ret[i - 1][j] + ret[i - 1][j - 1];
}
}
return ret;
}
};
【198】打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
按照五部曲进行推导
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
//确定dp数组 dp[i]存放最高金额
vector<int> dp(n);
if(n == 0)return 0;
if(n == 1)return nums[0];
if( n == 2)return max(nums[0],nums[1]);
dp[0] = nums[0];
dp[1] = max(nums[0],nums[1]);
for(int i = 2;i<n;i++){
dp[i] = max(dp[i-1],nums[i]+dp[i-2]);
cout<<dp[i]<<endl;
}
return dp[n-1];
}
};
【背包问题】
【0-1背包】
对于面试,掌握01背包和完全背包,多重背包。
基础引用:对于0,1背包,就是m个物品,给定对应的重量和价值,最大容量为n,这些物品你只能选一个或者不选(01),求最大价值。
动态规划五部曲:
(1)确定dp数组以及下标的含义dp[i] [j ]:表示下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
(2)确定递推公式:
- 放物品i:由dp[i - 1] [j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i] [j]就是dp[i - 1] [j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。)
- 放物品i:由dp[i - 1] [j - weight[i]]推出,dp[i - 1] [j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1] [j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
所以递归公式: dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i]);
(3)初始化dp数组
后面的公式是根据前面来推导的,所以初始化正确了才能导致dp数组正确
状态转移方程 dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。
要求出 dp[ 0 ] [ j]:也就是求种类0在不同重量下的最大价值:当j<weight[0]的时候肯定装不下,都为0.所以j从weight[0]开始初始化,都为value[0]:
(4)确定遍历顺序:先遍历物品,再遍历重量:
for(int i =1;i<m;i++){
for(int j = 0;j<=m;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]);//放
}
}
}
(5)举例推导dp数组
#include <iostream>
#include <unordered_map>
#include <vector>
using namespace std;
class Solution{
public:
int maxSolution( vector<int>& weight,vector<int>& value,int m,int n){
//确定dp数组
vector<vector<int>> dp(m,vector<int>(n+1,0));//要包含一个0
//初始化dp数组 dp[i-1][j] 初始化 dp[0][j]
for(int j = weight[0];j<=n;j++){
dp[0][j] = value[0];
}
for(int i =1;i<m;i++){//遍历背包种类 种类1已经初始化过了,要从2开始
for(int j = weight[0];j<=n;j++){//遍历重量
if(j<weight[i])dp[i][j] = dp[i-1][j];
else dp[i][j]= max( dp[i-1][j-weight[i]]+value[i],dp[i-1][j]);
}
}
cout<<dp[m-1][n]<<endl;
}
};
int main()
{
int m;//背包种类
int n;//空间容量 bagweight
vector<int> weight(m,0);
vector<int> value(m,0);
// cin >>m>>n;
// for(int i =0;i<m;i++){
// cin>> cap[i];
// }
// for(int i =0;i<m;i++){
// cin>> value[i];
// }
m = 3;//背包种类
n = 4;//最大容量是4
weight = {1,3,4};//重量
value = {15,20,20};//价值
Solution s;
int res = s.maxSolution(weight,value,m,n);
return 0;
}
【416】分割等和子集
0-1背包是可以用回溯的方式去做的,和【698】【473】都有相同的做法。
给你一个 只包含正整数 的 非空 数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
j:容量,dp[j]最大价值,可以看到都是倒叙取最大值,最后的dp数组是:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 1 | 1 | 1 | 5 | 6 | 6 | 6 | 6 | 10 | 11 |
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum =0;
for(auto num:nums){
sum+=num;
}
if(sum%2 == 1)return false;//要是不能平分直接退出
int n = sum/2;
vector<int> dp(n+1,0);//初始化dp数组
//dp遍历
for(int i =0;i<nums.size();i++){
for(int j=n;j>=nums[i];j--){//特别注意这个nums[i]
dp[j] = max(dp[j],dp[j-nums[i]]+nums[i]);
cout<<"i:"<<i<<" dp["<<j<<"]:"<<dp[j]<<endl;
}
}
//判断
if(dp[n] == n)return true;
return false;
}
};
【1049】最后一块石头的重量
有一堆石头,用整数数组 stones
表示。其中 stones[i]
表示第 i
块石头的重量。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x
和 y
,且 x <= y
。那么粉碎的可能结果如下:
- 如果
x == y
,那么两块石头都会被完全粉碎; - 如果
x != y
,那么重量为x
的石头将会完全粉碎,而重量为y
的石头新重量为y-x
。
最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0
。
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int n = stones.size();
int sum =0;
for(auto item:stones){
sum+=item;
}
int target = sum/2;
vector<int> dp(target+1,0);
for(int i =0;i<n;i++){
for(int j = target;j>=stones[i];j--){
dp[j] = max(dp[j],dp[j-stones[i]]+stones[i]);
}
}
return sum-2*dp[target];//注意最后返回的数值
}
};
【494】目标和
【17】一和零
【完全背包】
完全背包内层循环从头开始
【322】零钱兑换
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
//初始化dp数组
vector<int> dp(amount+1,INT_MAX);
dp[0] = 0;
for(int i =0;i<coins.size();i++){
for(int j = coins[i];j<=amount;j++){//遍历背包 注意初始化的位置
if(dp[j-coins[i]] !=INT_MAX){// 如果dp[j - coins[i]]是初始值则跳过
dp[j] = min(dp[j],dp[j-coins[i]]+1);
}
}
}
if(dp[amount] == INT_MAX) return -1;
return dp[amount];
}
};
遍历的过程:
以coins = [1,2,5],amount = 11为例子:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
初始化 | 0 | M | M | M | M | M | M | M | M | M | M | M |
1(只有1) | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
2(1或2) | 0 | 1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 5 | 5 | 6 |
5(1或2或5) | 0 | 1 | 1 | 2 | 2 | 1 | 2 | 2 | 3 | 3 | 2 | 3 |
【139】单词拆分
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
//存储wordDict 背包
unordered_set<string> wordMap(wordDict.begin(),wordDict.end());
vector<int> dp(s.size()+1,false);//dp数组
dp[0]= true;
//求的是排列数,有顺序,背包在外层
for(int i =1;i<=s.size();i++){//遍历背包
for(int j =0;j<i;j++){//遍历物品
string tmp = s.substr(j,i-j);
if(wordMap.find(tmp)!= wordMap.end()&&dp[j] == true){
dp[i] = true;
}
}
}
return dp[s.size()];
}
};
【子序列问题】
【300】最长子序列
(1)根据返回值确定dp[i]:以nums[i]结尾的最长子序列的数组长度。
(2)状态转移方程: if(nums[i]>nums[j])dp[i] = max(dp[i],dp[j]+1);(往前对比)
(3)dp数组初始化,dp[i] = 1
(4) 确定遍历顺序,dp[i+1] = dp[i]
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int res =1;
if(nums.size() == 1)return 1;
if(nums.size() == 0)return 0;
vector<int> dp(nums.size(),1);
for(int i =1;i<nums.size();i++){
for(int j = 0;j<i;j++){
if(nums[i]>nums[j])dp[i] = max(dp[i],dp[j]+1);
}
if(dp[i]>res)res = dp[i];//不一定是最后一个元素,取最长子序列
}
return res;
}
};
【301】