【力扣分模块练习】动态规划 dp基础与背包专题

学习资料:
代码随想录
https://github.com/youngyangyang04/leetcode-master

解题结构:


1.确定dp数组以及下标的含义

2.确定递推公式(状态转移方程)

3.初始化dp数组

4.确定遍历顺序和遍历边界

5.举例推导dp数组 (如果不对就打印出dp数组,看看和手推的有哪些不同,然后再改进)

基本动态规划 一维:


509. 斐波那契数
斐波那契数列的状态转移方程显而易见,dp[i]由dp[i-1]和dp[i-2]推出。从前向后遍历。

class Solution {
    
    
public:
	int fib(int n) {
    
    
        if(n <= 1) return n;
		vector<int> dp(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];
	}
};

70. 爬楼梯
由于一次只能走一步或者两步,所以当前第i级楼梯必然只能由i-1 和i-2走上来,因此又转化为了斐波那契问题。
dp中存到达这级台阶的方法的数量。由于第0,1级台阶分别是0种和1种方法,所以初始条件也是0和1。

class Solution {
    
    
public:
    int climbStairs(int n) {
    
    
        if(n <= 2)
        return n;

        vector<int> dp(n+1,1);
        for(int i = 2;i <= n;++i)
            dp[i] = dp[i-1] + dp[i-2];

        return dp[n];
    }
};

198. 打家劫舍
发现一个规律,DP中装的东西往往就是最优解的那个东西。 这里装有的是遍历到 i 这个店的最大钱数。 注意nums的i要减一,因为dp对应的多了个第0个店,和Nums中的下标含义不同。

扫描二维码关注公众号,回复: 13420110 查看本文章
class Solution {
    
    
public:
	int rob(vector<int>& nums) {
    
    
		if (nums.empty())
			return 0;
		int n = nums.size();
		vector<int> dp(n+1, 0);
		dp[1] = nums[0];
		for (int i = 2; i <= n; ++i)
		{
    
    
			dp[i] = max(dp[i - 1], dp[i - 2] + nums[i-1]);
		}

		return dp[n];
	}
};

96. 不同的二叉搜索树
给定一个整数 n,求以 1 … n 为节点组成的二叉搜索树有多少种?
输入: 3
输出: 5
解释:
给定 n = 3, 一共有 5 种不同结构的二叉搜索树:

1 3 3 2 1
\ / / / \
3 2 1 1 3 2
/ / \
2 1 2 3

思路:
可以发现n个节点,如果出现在子树中,组合数量是固定的。比如如果一个树的左子树只有2个节点,那么单论左子树的话必然只有2种组合方式。所以一个数字j做根节点的时候的组合总数就是–左儿子组合总数 * 右儿子组合总数。
这样,用一个可控制 j(根节点) 的for循环加上一个左右儿子的节点数对应的dp数量,就可以推出dp递推式。

class Solution {
    
    
public:
	int numTrees(int n) {
    
    
		vector<int> dp(n + 1, 0);
		dp[0] = 1; //注意,1是千万不能给初始值1的,因为1这个位置后面也参与了计算

		for (int i = 1; i <= n; i++) //目前一共有i个节点
		{
    
    
			for (int j = 1; j <= i; j++)//每个节点轮流做根
			{
    
    
				dp[i] += dp[j - 1] * dp[i - j];
			}
		}
		return dp[n];

	}
};

01背包要注意是逆向遍历。

二维DP写法:

class Solution {
    
    
public:
	int bags(vector<int>& weight, vector<int>& value, int bagweight)
	{
    
    
		int n = value.size();
		vector<vector<int>> dp(n, vector<int>(bagweight + 1, 0)); //开数组注意,容积j要开到bagweight + 1的大小

		for (int i = 0; i < n; i++)//j 容量为0的初始化  这步其实可以不要
			dp[i][0] = 0;
		for (int j = bagweight; j >= weight[0]; j--) //第一个物件的初始化 一定要倒序遍历
			dp[0][j] = dp[0][j - weight[0]] + value[0]; //防止一个物件重复放入

		for (int i = 1; i < n; 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]);  //择机更新状态
			}
			
		}	
		return dp[n - 1][bagweight];
	}
};

一维滚动DP写法:
相比之下,滚动的一维数组更不容易犯错。
滚动数组也就是把上一层的状态直接拷贝下来。这样,dp[j]在原地,就是原始状态。
详见:carl哥题解

class Solution {
    
    
public:
	int bags(vector<int>& weight, vector<int>& value, int bagweight)
	{
    
    
		int n = value.size();
		vector<int> dp(bagweight + 1, 0);

		for (int i = 0; i < n; i++) //遍历物件
		{
    
    
			for (int j = bagweight; j >= weight[i]; j--) //注意,由于每一层都是拷贝上层过来的,所以相当于每次搞容积都是一次初始化
			{
    
    
				dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
			}
		}

		return dp[bagweight];
	}
};

我将过程打印出来,正确的写法过程是这样的:

//若输入为:
	vector<int> weight = {
    
    1 , 3, 4};
	vector<int> value = {
    
     15,20,30 };
	int bagweight = 5;
	
 0 15 15 15 15 15
 0 15 15 20 35 35
 0 15 15 20 35 45

注意两点:
1.for循环一定要是遍历物件的在外层 (否则你想想连j >= weight[i]都没法描述)
2.j的那层for循环(即操作容量的那层循环),一定要是倒序 (否则小容量的结果会重复计算,影响大容量的,把它想成一次初始化过程就好理解了)

接下来的题就抽象了起来
416. 分割等和子集

给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意:

每个数组中的元素不会超过 100
数组的大小不会超过 200

示例 1:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].

可以把全部数的和算出来,等大子集之和一定是sum/2。所以,即用物件{1,5,11,5}填满容量为sum/2的背包,正好容量J增长到sum/2时,里面放置的价值也就是sum/2。所以,这题的weight和value都是nums里面的数字。

别的和基础的01背包一样。

class Solution {
    
    
public:
	bool canPartition(vector<int>& nums) {
    
    
		int n = 0; // sum
		for (int i = 0; i < nums.size(); i++)
			n += nums[i];
		if (n % 2 == 1)
			return false;
		n = n / 2;

		vector<int> dp(n+1,0);

		for (int i = 0; i < nums.size(); i++) //遍历物件
		{
    
    
			for (int j = n; j >= nums[i]; j--) //遍历容量
			{
    
    
				dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);		
			}
		}
		if (dp[n] == n) //考察sum/2的时候是否有正好放满
			return true;
		else
			return false;
	}
};

1049. 最后一块石头的重量 II
高度类似416。

思想:试图把石头分为重量尽可能接近的两堆->即,用两个容量差不多的包包去装这些石头,最后用两个包中实际装有的石头来相减,得出结果。

dp[j]和416一样,表示容量为j是,背包中最多实际装有的价值(本题中是重量)。

最后return那里,由于一开始n是由sum向下取整得来,所以sum - dp[n] (第一个背包)一定大于dp[n] (第二个背包)。

class Solution {
    
    
public:
	int lastStoneWeightII(vector<int>& stones) {
    
    
		int sum = 0;
		for (int i = 0; i < stones.size(); i++)
			sum += stones[i];  //sum
		int n = sum / 2;
		vector<int> dp(n + 1, 0);

		for (int i = 0; i < stones.size(); i++)
		{
    
    
			for (int j = n; j >= stones[i]; j--)
			{
    
    
				dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
			}
		}
		return (sum - dp[n]) - dp[n];
	}
};

474. 一和零 三维01背包
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。
请你找出并返回 strs 的最大子集的大小,该子集中 最多 有 m 个 0 和 n 个 1 。
如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。

示例:
输入:strs = [“10”, “0001”, “111001”, “1”, “0”], m = 5, n = 3
输出:4
解释:最多有 5 个 0 和 3 个 1 的最大子集是 {“10”,“0001”,“1”,“0”} ,因此答案是 4 。
其他满足题意但较小的子集包括 {“0001”,“1”} 和 {“10”,“1”,“0”} 。{“111001”} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。

题解:
要用点空间想象能力。其实就是下一层的平面复制上一层的数据作滚动数组。在当前平面维护数据更新的时候要用逆序(其实理解了就是废话,正序每次更新会覆盖掉之前的,就搞乱了)。

class Solution {
    
    
public:
	int findMaxForm(vector<string>& strs, int m, int n) {
    
    
		
		vector<vector<int>> dp(m + 1, vector<int>(n+1,0));

		for (int k = 0; k < strs.size(); k++)
		{
    
    
			int zeronum = 0, onenum = 0;   //找出要占用多少个0 和1的空间
			for (int i = 0; i < strs[k].size(); i++)
			{
    
    
				if (strs[k][i] == '0')
					zeronum++;
				else
					onenum++;
			}
			for (int i = m; i >= zeronum; i--)
			{
    
    
				for (int j = n; j >= onenum; j--)
				{
    
    
					dp[i][j] = max(dp[i][j], dp[i - zeronum][j - onenum] + 1); //刨去当前物件的方法数+1
				}
			}
		}
		return dp[m][n];
	}
};

494. 目标和
给定一个非负整数数组,a1, a2, …, an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。
返回可以使最终数组和为目标数 S 的所有添加符号的方法数。

示例:
输入:nums: [1, 1, 1, 1, 1], S: 3
输出:5
解释:
-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3
一共有5种方法让最终目标和为3。

思路:
这题难点有2,其一是如何把当前问题转化为一个 分割问题。如果有这个思路,就可以一顿推理,

left = right + target;
left = sum - left + target;
left = (sum + target)/2;

即求出某部分和等于(sum + S)/2;

第二个难点就是分割问题的递推公式。

dp[j] += dp[j - nums[i]];
含义为,刨去当前数字,所剩下的和,一共有X种方法,所以放入当前物件nums[i]后,多出了 1 * X种方法,所以当前容量J就可以放 dp[j] + dp[j]-nums[i]种方法。

这个公式在动归排列组合中常用。

class Solution {
    
    
public:
	int findTargetSumWays(vector<int>& nums, int S) {
    
    
		int sum = 0;
		for (int i = 0; i < nums.size(); i++)
			sum += nums[i];
        if(S > sum)  //为了过 {1,2,7,9,981}  10000000这个测试点
            return 0;
        if((sum+S) % 2 == 1)//和是奇数,一半则是个小数,没有解
            return 0;
		int t = (sum + S) / 2; //要凑出的目标数字大小
      
		vector<int> dp(t+1, 0);
        dp[0] = 1;
		for (int i = 0; i < nums.size(); i++)
		{
    
    
			for (int j = t; j >= nums[i]; j--)
			{
    
    
				dp[j] += dp[j - nums[i]];
			}
		}

		return dp[t];
	}
};

完全背包


完全背包其实就是for中的遍历顺序不同。 正向遍历代表可以重复使用Nums中的元素

518. 零钱兑换 II – 组合问题(子集结果顺序不敏感)

给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。
示例 1:
输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

思路:
coins中元素可以重复使用,所以是个完全背包。
随后发现结果子集中的元素顺序不敏感,如{1,2,5}和{5,1,2}是一种结果。所以是一个组合问题。

利用模板,组合问题的解法,外层for循环是遍历物件。

递推公式:

dp[j] = dp[j] + dp[j-nums[i]] * 1;
//本轮方法数 = 上一轮的方法数(仅使用之前的物件) + 不算现在加进来的这个物件的权值,有多少种方法 * 1 (1代表加入这个物件后,就是一种方法了)

//所以化简后:
dp[j] += dp[j - nums[i]];

所以本题代码:

class Solution {
    
    
public:
    int change(int amount, vector<int>& coins) {
    
    
        vector<int> dp(amount+1,0);
        dp[0] = 1;
        for(int i = 0;i < coins.size();i++) //组合问题,遍历物件在外(常规写法)
        {
    
    
            for(int j = 0;j <= amount;j++)
            {
    
    
                if(j >= coins[i])
                    dp[j] += dp[j-coins[i]];
            }
        }

        return dp[amount];
    }
};

377. 组合总和 Ⅳ – 排列问题(子集顺序敏感)
给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。

示例:
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。

思路:
和上题唯一的不同就是子集结果的顺序敏感了。这里一定要把背包空间的遍历放外面:
因为如果还是物件在外,那么后遍历到的物件就只能在子集中的靠后位置,如背包为4时,只有{1,3}没有{3,1}了,这样就会少好几种结果。

class Solution {
    
    
public:
    int combinationSum4(vector<int>& nums, int target) {
    
    
            int n = nums.size();
            vector<int> dp(target+1,0);
            dp[0] = 1;

            for(int i = 0;i <= target;i++) //外层背包
            {
    
    
                for(int j = 0;j < n;j++) //内层物件
                {
    
    
                    if(i >= nums[j] && dp[i] < INT_MAX - dp[i-nums[j]]) //后面这个是为了过一个溢出测试点
                        dp[i] += dp[i - nums[j]];
                }
            }

            return dp[target];
    }
};

猜你喜欢

转载自blog.csdn.net/abyss_miracle/article/details/112254121