贪心算法一般分为如下四步:
(1)将问题分解为若干个子问题
(2)找出适合的贪心策略
(3)求解每一个子问题的最优解
(4)将局部最优解堆叠成全局最优解
其实这个分的有点细了,真正做题的时候很难分出这么详细的解题步骤,可能就是因为贪心的题目往往还和其他方面的知识混在一起。
对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!
确定dp数组(dp table)以及下标的含义
(1)确定递推公式
(2)dp数组如何初始化
(3)确定遍历顺序
(4)举例推导dp数组
一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢?因为一些情况是递推公式决定了dp数组要如何初始化!后面的讲解中我都是围绕着这五点来进行讲解。可能刷过动态规划题目的同学可能都知道递推公式的重要性,感觉确定了递推公式这道题目就解出来了。其实 确定递推公式 仅仅是解题里的一步而已!一些同学知道递推公式,但搞不清楚dp数组应该如何初始化,或者正确的遍历顺序,以至于记下来公式,但写的程序怎么改都通过不了。
后序的讲解的大家就会慢慢感受到这五步的重要性了。
- 贪心-分发饼干
最简单的贪心题目,对于贪心题目,还有一个常用的技巧就是能排序就先排序。
class Solution {
public int findContentChildren(int[] g, int[] s) {
Arrays.sort(g);
Arrays.sort(s);
int start = 0;
int count = 0;
for (int i = 0; i < s.length && start < g.length; i++) {
if (s[i] >= g[start]) {
start++;
count++;
}
}
return count;
}
}
- 动态规划-斐波那契数列
这里需要严格遵循动态规划的5个步骤
class Solution {
public int fib(int n) {
if(n <= 1)
return n;
Integer[] dp = new Integer[n+1];
dp[0] = 0;
dp[1] = 1;
for(int i = 2; i <= n; i ++){
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
- 贪心-最大子序和
这道题也可以使用动态规划来做,动态规划的状态转移方程就是要么去累计和,若为负数就去当前的dp[i]。
class Solution {
public int maxSubArray(int[] nums) {
if(nums.length == 1)
return nums[0];
int result = Integer.MIN_VALUE;
int count = 0;
for(int i = 0; i < nums.length; i ++){
count += nums[i];
// 取区间累计的最大值(相当于不断确定最大子序终止位置)
if(count > result){
result = count;
}
// 相当于重置最大子序起始位置,因为遇到负数一定是拉低总和
if(count <= 0){
count = 0;
}
}
return result;
}
}
- 动态规划-使用最小花费爬楼梯(每上一层楼梯有一个cost,可以爬一层或者两层)
//原创算法
class Solution {
public int minCostClimbingStairs(int[] cost) {
if(cost.length <= 1)
return cost[0];
if(cost.length == 2)
return Math.min(cost[0], cost[1]);
Integer[] dp = new Integer[cost.length];
dp[0] = cost[0];
dp[1] = cost[1];
for(int i = 2; i < cost.length; i ++){
if(i == cost.length - 1){
return Math.min(dp[i - 1], dp[i - 2] + cost[i]);
}
dp[i] = Math.min(dp[i - 1] + cost[i], dp[i - 2] + cost[i]);
System.out.println(i + " " + dp[i]);
}
return dp[cost.length - 1];
}
}
- 贪心-跳跃游戏
发现贪心是真的没有固定的套路,只能针对每一个问题具体分析
class Solution {
public boolean canJump(int[] nums) {
int cover = 0;
if(nums.length == 1)
return true;
for(int i = 0; i <= cover; i ++){
cover = Math.max(i + nums[i], cover);
if(cover >= nums.length - 1)
return true;
}
return false;
}
}
- 动态规划-不同路径2(路径中存在障碍)
主要思路就是存在障碍的地方dp为0,状态转移方程不变
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int n = obstacleGrid.length, m = obstacleGrid[0].length;
int[][] dp = new int[n][m];
dp[0][0] = 1 - obstacleGrid[0][0];
for (int i = 1; i < m; i++) {
if (obstacleGrid[0][i] == 0 && dp[0][i - 1] == 1) {
dp[0][i] = 1;
}
}
for (int i = 1; i < n; i++) {
if (obstacleGrid[i][0] == 0 && dp[i - 1][0] == 1) {
dp[i][0] = 1;
}
}
for (int i = 1; i < n; i++) {
for (int j = 1; j < m; j++) {
if (obstacleGrid[i][j] == 1) continue;
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[n - 1][m - 1];
}
}
- 动态规划-01背包
状态转移方程为什么是这个:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
总体来说就是看第i个物品放不放进背包里,放进背包里产生的价值大还是不放进背包里产生的价值更大。
首先dp[i][j] = dp[i-1][j]是容易理解的,就是第i个物品不放进背包里,所以dp[i][j]与dp[i-1][j]的价值是一样的;dp[i][j] = dp[i-1][j-weight[i]] + value[i]的意思就是i放进背包里,dp[i-1][j-weight[i]]中的j-weight[i]就是为即将放入的第i个物品腾出容量,看看前i-1个物品,目前容量是j-weight[i],还能放入一个weight[i]重量的物品,而第i个物品恰好重量就是weight[i],所以将第i个物品放入背包中,同时再加上value[i],就是第i个物品放入背包中产生的最大价值。
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++) {
// 遍历背包容量,这里j为什么不从weight[0]开始而是从0开始,
//如果weight是按照从小到大的顺序排列的,那么从0或者从weight[0]开始
//结果都是一样的,但是如果weight没有按照从小到大开始排序的的话,那么从weight[0]开始
//的话,weight中较小的数据就不会正常的执行,也就是j较小的那几列数据就是错误的
for(int j = 0; j <= bagWeight; j++) {
//这里如果不加if的话,会j-weight[i]会负数越界
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;
}
- 目标和(给定一个非负整数数组,a1, a2, …, an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。返回可以使最终数组和为目标数 S 的所有添加符号的方法数。)
第一思路是回溯,也确实可以用回溯,然后就是想办法使用DP降低时间复杂度,使用下述思路进行转化:
最终将数组中的数据分为正数堆left与需要转化成负数的堆right,left-right=S,left+right=sum,sum是固定的,left=(S+sum)/2,最终将问题转化为在数组中查找和为left的方法数。
dp[j]表示填满j(包括j)这么大容积的包,有dp[j]种方法
递推公式:不考虑nums[i]的情况下,填满容量为j - nums[i]的背包,有dp[j - nums[i]]种方法。那么只要搞到nums[i]的话,凑成dp[j]就有dp[j - nums[i]] 种方法。举一个例子,nums[i] = 2: dp[3],填满背包容量为3的话,有dp[3]种方法。那么只需要搞到一个2(nums[i]),有dp[3]方法可以凑齐容量为3的背包,相应的就有多少种方法可以凑齐容量为5的背包。那么需要把 这些方法累加起来就可以了,dp[j] += dp[j - nums[i]]
所以求组合类问题的公式,都是类似这种:
dp[j] += dp[j - nums[i]]
初始化:dp[0]=1,装满容量为0的背包,方法有1种,就是装0件物品。
dp[j]其他下标对应的数值应该初始化为0,从递归公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来。
对于01背包问题一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序。
代码如下:
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int i = 0; i < nums.length; i++) sum += nums[i];
if ((target + sum) % 2 != 0) return 0;
int size = (target + sum) / 2;
int[] dp = new int[size + 1];
dp[0] = 1;
for (int i = 0; i < nums.length; i++) {
for (int j = size; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
return dp[size];
}
}
- 组合总和
给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。
示例:
nums = [1, 2, 3] target = 4
所有可能的组合为: (1, 1, 1, 1) (1, 1, 2) (1, 2, 1) (1, 3) (2, 1, 1) (2, 2) (3, 1)
请注意,顺序不同的序列被视作不同的组合。
因此输出为 7。
***注意:
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
本题为完全背包问题,完全背包问题在遍历内循环时需要从前往后遍历,这样可以对元素进行重复使用。
所以本题遍历顺序最终遍历顺序:target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历。
class Solution {
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target + 1];
dp[0] = 1;
for(int j = 0; j <= target; j ++){
for(int i = 0; i < nums.length; i ++){
if(j - nums[i] >= 0)
dp[j] += dp[j - nums[i]];
}
}
return dp[target];
}
}