前言:
最近做了几个题目,乍看题目都感觉可以采用dfs的方式搜索得到。然而最终结果都是超时。而合理的解法都是动态规划! 因此,可以说动态规划是对深度优先这种暴力搜索的简化、记忆化搜索。而动态规划的难点在于寻找状态方程和最优结构。
注意到判断一道题是否可以用动态规划的思想来求解,首先要确定这个问题的求解过程是否符合最优子结构的寻找过程。即这些子问题是互相独立,互不干扰的。
我们先来看下面几道题。
Leetcode322: 零钱兑换
题目:
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。你可以认为每种硬币的数量是无限的。
深搜超时代码:
class Solution {
vector<int>res;
public:
int coinChange(vector<int>& coins, int amount) {
//用深搜,如果大于了就返回。如果正好等于返回这个num
dfs(coins, 0,0, amount);
//考虑下无解的情况
if(res.size()==0)
return -1;
sort(res.begin(),res.end());
return res[0];
}
//深搜程序
void dfs(vector<int>& coins, long sum,int index,int amount){
if(sum>amount)
return;
if(sum==amount)
res.push_back(index);
for(int i=0;i<coins.size();i++){
sum+=coins[i];
dfs(coins,sum,index+1,amount);
sum-=coins[i];
}
}
};
新思路:
换个思路可以用动态规划的思想来求解。注意到怎么符合这个题目的最优子结构呢?我们知道一些题目中动态规划和他的前一个元素或者前两个元素相关联。
但是硬币是离散的互项影响,一个金额可以由不同种类硬币获得,所以不能直接用连续关联。而应该用硬币中的离散变量做关联! 取得目标金额,什么意思呢?就是我们确定了某个金额必定是来自之前金额的叠加,每次都回退对应的金额。我们取得当前金额下最少的张数。我们的目标金额和当前金额完全不冲突。因为目标金额要来自回退的可选列表!
一开始我们给这个搜索数组赋值超过金额数,在循环中取最小值即可。如果这个金额我们取不到可行解肯定是大于amount的,那就返回-1,如果取得可行解肯定小于等于amount嘛。
class Solution {
vector<int>res;
public:
int coinChange(vector<int>& coins, int amount) {
int Max=amount+1;
vector<int>dp(amount+1,Max);//定义一维数组中的元素都为Max
dp[0]=0;
for(int i=1;i<=amount;i++){
for(auto &coin:coins){
if(i>=coin) //负的就不用比较了 说明无法达到这个值,给他很大的值就好了和原来一样
dp[i]=min(dp[i],dp[i-coin]+1);
}
}
return dp[amount]>amount ? -1:dp[amount];
}
};
上面的dp解法是从下至上,我们也可以弄一种从上至下的搜索树。对搜索树进行记忆化操作 ,有点像斐波那契额数列的递归之记忆化处理,避免重复解 。因为递归的总有一条路要先打通。
class Solution {
vector<int>count;
int dp(vector<int>& coins, int rem) {
if (rem < 0) return -1;
if (rem == 0) return 0;
if (count[rem - 1] != 0) return count[rem - 1];
int Min = INT_MAX;
for (int coin:coins) {
int res = dp(coins, rem - coin);
if (res >= 0 && res < Min-1) { //在迭代过程中min不断减少,res要更小我们才取~
Min = res + 1;
}
}
count[rem - 1] = Min == INT_MAX ? -1 : Min;
return count[rem - 1];
}
public:
int coinChange(vector<int>& coins, int amount) {
if (amount < 1) return 0;
count.resize(amount);
return dp(coins, amount);
}
};
518. 零钱兑换 II
题目:
给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。注意,金额是0的时候方案是1,就是什么也不选择。
思路:
这道题是零钱兑换1的变体。我们可以设置dp数组表示凑到金额i时候的方案数,很明显方案数是来自之前方案数的继承!我们对每个硬币进行枚举,注意到这个金额来自i-coin的可能性。我们要保证i-coin大于等于0不然没意义 所以i从coin开始遍历。假设i-coin==0,说明这个硬币恰好等于金额,方案+1.
先枚举硬币的好处是能够避免重复,就是比如3,数组是2 和1, 每次dp数组叠加来的结果肯定是按顺序的,21 12是一种不会重复,肯定是先2再1 枚举硬币1的时候,肯定是寻找2之前走完后的痕迹。
//这个思路比较乱,还是看代码随想录的完全背包问题分析
注意到就是组合问题用dp就是+的形式,然后01背包逆序,完成背包顺序,顺序是为了这个东西可以多次取用。然后组合问题是恰好凑到这个下标的值,最大值问题是可以有这么大容量下的最大价值。
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount + 1);
dp[0] = 1;
for (int& coin : coins) {
for (int i = coin; i <= amount; i++) {
dp[i] += dp[i - coin]; //用的情况下还能继续用。或者就上次不用或者就是用的
//举个例子就是2 4元面币来填
}
}
return dp[amount];
}
};
279. 完全平方数
题目:
给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, …)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。给你一个整数 n ,返回和为 n 的完全平方数的 最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
代码:
class Solution {
class Solution {
public:
int numSquares(int n) {
vector<int>dp(n+1,n);
unordered_set<int>set;
dp[0]=1;
for(int i=1;i<=n;i++){
double m=sqrt(i);
if(m==(int)m){ //说明数字 i是 完全平方数,本身是完全平方数的话直接一个i就兴了,个数1
dp[i]=1;
set.insert(i); //哈希表存放完全平方数,小于等于i的平方数每次检索
}
else{ //针对不是完全平方数的情况
for(auto & each:set)
dp[i]=min(dp[i], dp[i-each]+1); //从2开始就是dp1可以找到了所以 最不济情况就是1的平方一直加到i
}
}
return dp[6];
}
};
第二种思路,详情看leetcode
class Solution {
public:
int numSquares(int n) {
vector<int> f(n + 1);
for (int i = 1; i <= n; i++) {
int minn = INT_MAX;
for (int j = 1; j * j <= i; j++) {
minn = min(minn, f[i - j * j]);
}
f[i] = minn + 1;
}
return f[n];
}
};
Leetcode53: 最大子序和
题目:
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n=nums.size();
vector<int>dp(n);
dp[0]=nums[0]; //以XX为结尾的最大值注意以XX结尾,必须选择!!!而不是最大可以选择到这里
int maxn=dp[0];
for(int i=1;i<nums.size();i++){
dp[i]=max(dp[i-1]+nums[i],nums[i]);
maxn=max(maxn,dp[i]);
}
return maxn;
}
};
上面代码的空间复杂度高了,给他优化下:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n=nums.size();
int pre=0;
int maxn=nums[0];
for(int i=0;i<nums.size();i++){
pre=max(pre+nums[i],nums[i]);
maxn=max(maxn,pre);
}
return maxn; //题目至少包含一个元素,至少要选择,不选择也不是0,所以最大值先给一开始的
}
};
————————————————————————————————————————
背包9讲问题系列
416 分割等和子集
题目:
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
思路:
这道题是很典型的01背包问题,01背包问题是背包问题中最简单也是最基础的一块。意思就是有个背包容量为V你有n个物品每个物品中wi,寻求一个二维数组计算最大i个物体放进容量为j的背包恰好能装满的情况是否存在或者最大能够装多少!
题目的原始问法是:有N件物品和一个容量为V的背包。第i 件物品的费用是w[i],价值是v[i],求哪些些物品装入背包可使价值总和最大。
基本思路:这是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放。用子问题定义状态:即f[i][j]表示前i件物品恰放入一个容量为 j 的背包可以获得的最大价值。则其状态转移方程便是:
f[i][j]=max(f[i−1][j],f[i−1][j−w[i]]+v[i])
注意到我们的问题或者求解思路是恰好,这样能够最大化,那最大利润有时候比如被背包容量7,东西重6,那我就不放了么,不是的,当j到6的时候恰好装满了,然后我7的时候不放就行了。然后初值的赋值方面,如果只是问最大利润,初值给0就行了,如果是恰好装满,一般赋值负无穷,就无法装满嘛。只能吃老本
这道题目可以把总和分成一半,两个相同数组想象成一个背包一个外部容器,放和不放嘛,让数组元素恰好装满背包,背包容量是数组总和一半。
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
if (n < 2) {
return false;
}
int sum = accumulate(nums.begin(), nums.end(), 0);
int maxNum = *max_element(nums.begin(), nums.end());
if (sum & 1) {
return false;
}
int target = sum / 2;
if (maxNum > target) {
return false;
}
vector<vector<int>> dp(n, vector<int>(target + 1, 0));
for (int i = 0; i < n; i++) {
dp[i][0] = true;
}
dp[0][nums[0]] = true;
for (int i = 1; i < n; i++) {
int num = nums[i];
for (int j = 1; j <= target; j++) {
if (j >= num) {
dp[i][j] = dp[i - 1][j] | dp[i - 1][j - num];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[n - 1][target];
}
};
- 完全背包问题
其实前面讲到的 零钱兑换问题 就是完全背包问题,详情可以看上面的链接。做这种完全背包问题的时候,一定要注意,物品是可以无限取的,所以选择当前物品放入背包以后是不会影响物品的选择范围的,这在状态转移方程中尤为重要。
跟01背包一样,完全背包也是一个很经典的动态规划问题,不同的地方在于01背包问题中,每件物品最多选择一件,而在完全背包问题中,只要背包装得下,每件物品可以选择任意多件。从每件物品的角度来说,与之相关的策略已经不再是选或者不选了,而是有取0件、取1件、取2件…直到取⌊T/Vi⌋(向下取整)件。
———————————————————————————————————————————
关于完全背包等,滚动数组,建议看卡尔的pdf。 下面上一道题目。看题目来理解吧。
总结下就是01 背包取和不取,每个东西只能取1次来获取最大利润,dp[i][j]表示最大取到前i个物体j这么大容量下的最大利润,所以可以不装满。01背包问题还可以将利润变成和重量一样,这样就成为了dp[i][j]是表示最大取到前i个物体的情况下j这么大容量下能装下来的最大重量。这种问题常见于数组取物,而没有价值,我来取数组的数值来凑重量。初值如果数组没负数,那最大就是给0,然后背包容量0的话利润全部给0,装第一个东西开始,背包容量比第一个容量大就全部给第一个物体的价值。
滚动数组就是让dp[i][j]继承dp[i-1][j],所以就没有了i,一行行滚动下来。但是要做好初值的赋值。还有个就是组合一般赋值是dp[0]=1;为了对同一个元素不多次选择,选择逆序,反正搜索方向都是左上角,这个都能保证,所以没事。
01背包也可以用来求组合问题,这种问题下dp不再是max而是累加,一个典型的问题就是494目标和。组合问题最重要的就是,==dp[j]表示物体正好装满j的容量下的最大组合个数,注意到是正好!==而之前的01背包最大值问题是最大这么大空间的情况下最大利润。所以组合问题不用这么多初值,当物体正好和容量一致,方案数+1
dp[i][j]=dp[i-1][j]+dp[i-1][j-nums[i]]
或者用滚动数组的话逆序j输出:
dp[j]=dp[j]+dp[j-nums[i]]
然后完全背包问题,就是这个物体可以拿无数次!和01背包的区别就是第二行逆序的时候我们正序让物体可以拿很多次。如果不嫌弃麻烦用两维度数组表示就是:
dp[i][j]=dp[i-1][j]+dp[i][j-nums[i]]
可以看到和01背包问题的话就是第二项目i-1变成了i,就是这次拿的情况下可以判断继续拿的最大利润或者不拿就上次的
然后完全背包也可以用来求组合和排列的问题。组合就是凑count个数嘛,和之前的一样,只是倒序变正序,==如果是求排列,那就将两个循环换下,容量循环在前面,物体重量循环在后面。 推导可以用1 5两个重量装6背包来试试 15 51 两个解。==注意到组合这种问题就是把利润去掉了,用重量来填背包数组。
下面代码就是排列的,换下循环就是组合的 注意组合排列都是恰好装满
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
//可以重复使用,然后是组合不能看排列ok了完全背包啊,注意等于一个值就是+;
//背包容量是target,物品的重量是里面数组的元素,这里没有价值,而是组合个数存的是组合个数
//dp[j]=a 表示正好!注意组合是正好,正好凑到j的目标值所具备的组合个数
//先遍历背包再遍历元素能找出所有排列的情况,不是针对某一位出现位置,而是针对所有排列!数字排列我们要的就是数字排列。
int n=nums.size();
vector<int>dp(target+1,0);
dp[0]=1;
for(int j=0; j<=target;j++){
for(int i=0; i<nums.size();i++){
if(j>=nums[i] && dp[j] < INT_MAX - dp[j - nums[i]])
dp[j]+=dp[j-nums[i]];
}
}
return dp[target];
}
};
另外一个组合的例子爬楼梯问题,当爬楼梯从每次1步或者2步如果变成每次3步 4步 5步这么多组合如何求爬楼梯的的组合个数呢?这个涉及到排列
class Solution {
public:
int climbStairs(int n) {
vector<int>dp(n+1,0);
vector<int>w(2);
w[0]=1;w[1]=2;
//求全组合的动态规划题目
//用完全背包问题,就是可以用好几次,然后顺序可以换,完全背包顺序,然后顺序可以换就是for循环变下
dp[0]=1;
for(int i=1;i<=n;i++){//先遍历背包容量
for(int j=0;j<w.size();j++){
if(i>=w[j])
dp[i]+=dp[i-w[j]];
}
}
return dp[n];
}
};
但是不要乱花渐欲迷人眼,被完全背包和01背包迷惑,很多问题是对区间动态规划或者就是建立状态转移方程,爬楼梯的简单写法就是:
class Solution {
public:
int climbStairs(int n) {
vector<int>dp(n+1,0);
if(n<=2)
return n;
dp[1]=1;
dp[2]=2;
for(int i=3;i<=n;i++)
dp[i]=dp[i-1]+dp[i-2];
return dp[n];
}
};
除此之外,看零钱兑换问题,很明显可以重复使用,但是注意到状态转移方程还是要稍微变化,虽然可以说是完全背包问题。但是状态转移方程很明显是求最小值!
在这里就要进行动态规划五部曲了!!
最终附上代码:
上面的题目也告诉了我们完全背包的新形式,min,好! 我们进一步把上面的完全平方和问题进行完全背包min求解,再一次进行动态规划五部曲熟悉!注意到min和组合排列问题都是恰好装满的情况下
总结:
01背包2重循环顺序可以变,但是滚动数组不能变;
完全背包问题2重循环顺序可以变,针对存最大容量的话。
如果是设计到组合和排列,组合是先物体再背包;排列是背包再物体/