算法训练Day44 动态规划专题- 背包问题 | 完全背包基础知识;LeetCode518. 零钱兑换(装满背包有多少种方法,组合数);377.组合总和IV(装满背包有多少种方法,排列数)

前言:

算法训练系列是做《代码随想录》一刷,个人的学习笔记和详细的解题思路,总共会有60篇博客来记录,计划用60天的时间刷完。 

内容包括了面试常见的10类题目,分别是:数组,链表,哈希表,字符串,栈与队列,二叉树,回溯算法,贪心算法,动态规划,单调栈。

博客记录结构上分为 思路,代码实现,复杂度分析,思考和收获,四个方面。

如果这个系列的博客可以帮助到读者,就是我最大的开心啦,一起LeetCode一起进步呀;)

目录

完全背包基础知识

1. 思路

2. 代码实现

3. 复杂度分析

4. 思考与收获

LeetCode518. 零钱兑换

1. 思路

2. 代码实现

3. 复杂度分析

4. 思考与收获

LeetCode377.组合总和IV

1. 思路

2. 代码实现

3. 复杂度分析

4. 思考与收获


完全背包基础知识

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

完全背包和01背包问题唯一不同的地方就是,每种物品有无限件

同样leetcode上没有纯完全背包问题,都是需要完全背包的各种应用,需要转化成完全背包问题,所以我这里还是以纯完全背包问题进行讲解理论和原理。

1. 思路

在下面的讲解中,我依然举这个例子:

背包最大重量为4。

物品为:

每件商品都有无限个!问背包能背的物品最大价值是多少?

  本题不再按照动态规划五部曲来讲解,而是直接讲解完全背包和01背包在思路上面和代码实现上面的区别。

区别1:遍历顺序不同

首先在回顾一下01背包的核心代码

// 01背包
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]);
    }
}

我们知道01背包必须先遍历物品,再遍历背包,其中内嵌的循环是从大到小遍历(逆序),为了保证每个物品仅被添加一次。

而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:

// 完全背包
// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = weight[i]; j <= bagWeight ; j++) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

为什么呢?

要理解这个区别,最好的办法是举例子,然后模拟状态转移的过程;

01背包dp状态图如下:每层的 j 逆序遍历。

算法训练Day44 动态规划专题- 背包问题 | 完全背包基础知识;LeetCode518. 零钱兑换(装满背包有多少种方法,组合数);377.组合总和IV(装满背包有多少种方法,排列数)_第1张图片

 完全背包dp状态图如下:每层的 j 正序遍历。

算法训练Day44 动态规划专题- 背包问题 | 完全背包基础知识;LeetCode518. 零钱兑换(装满背包有多少种方法,组合数);377.组合总和IV(装满背包有多少种方法,排列数)_第2张图片

区别2:也可以先遍历背包,再遍历物品,for循环可颠倒

在01背包中,二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。

在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!

因为dp[j] 是根据 下标j之前所对应的 dp[j] 计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。

要理解这个区别,最好的办法还是举例子,然后模拟状态转移的过程;

完全背包中 先遍历物品再遍历背包

// 完全背包
// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = weight[i]; j <= bagWeight ; j++) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

遍历物品在外层循环,遍历背包容量在内层循环,状态如图:

算法训练Day44 动态规划专题- 背包问题 | 完全背包基础知识;LeetCode518. 零钱兑换(装满背包有多少种方法,组合数);377.组合总和IV(装满背包有多少种方法,排列数)_第3张图片

完全背包中 先遍历背包,再遍历物品

// 完全背包
// 先遍历背包,再遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
    cout << endl;
}

遍历背包容量在外层循环,遍历物品在内层循环,状态如图:

算法训练Day44 动态规划专题- 背包问题 | 完全背包基础知识;LeetCode518. 零钱兑换(装满背包有多少种方法,组合数);377.组合总和IV(装满背包有多少种方法,排列数)_第4张图片

看了这两个图,大家就会理解,完全背包中,两个for循环的先后循序,都不影响计算dp[j]所需要的值(这个值就是下标j之前所对应的dp[j])

01背包中 如果先逆序遍历背包,再遍历物品呢?

// 01背包
// 先遍历背包,再遍历物品
for(int j = bagWeight; j >= 0; j--) { // 逆序遍历背包容量
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        if (j - weight[i] >= 0) 
					 dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
    cout << endl;
}

dp状态转移如图:

算法训练Day44 动态规划专题- 背包问题 | 完全背包基础知识;LeetCode518. 零钱兑换(装满背包有多少种方法,组合数);377.组合总和IV(装满背包有多少种方法,排列数)_第5张图片

求出来是:背包可装下的最大的单件物品的价值

2. 代码实现

# 先遍历物品,再遍历背包
def test_complete_pack1():
    weight = [1, 3, 4]
    value = [15, 20, 30]
    bag_weight = 4

    dp = [0]*(bag_weight + 1)

    for i in range(len(weight)):
        for j in range(weight[i], bag_weight + 1):
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
    
    print(dp[bag_weight])

# 先遍历背包,再遍历物品
def test_complete_pack2():
    weight = [1, 3, 4]
    value = [15, 20, 30]
    bag_weight = 4

    dp = [0]*(bag_weight + 1)

    for j in range(bag_weight + 1):
        for i in range(len(weight)):
            if j >= weight[i]: dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
    
    print(dp[bag_weight])

if __name__ == '__main__':
    test_complete_pack1()
    test_complete_pack2()

3. 复杂度分析

  • 时间复杂度:O(N*bagSize);

    其中N为物品个数,bagSize为背包容量,两层for loop需要遍历的;

  • 空间复杂度:O(bagSize);

    其中bagSize为背包容量,dp数组的大小为bagSize。

4. 思考与收获

  1. 细心的同学可能发现,**全文我说的都是对于纯完全背包问题,其for循环的先后循环是可以颠倒的!**但如果题目稍稍有点变化,就会体现在遍历顺序上。

    如果问装满背包有几种方式的话? 那么两个for循环的先后顺序就有很大区别了,而leetcode上的题目都是这种稍有变化的类型。

  2. 这个区别,我将在后面讲解具体leetcode题目中给大家介绍,因为这块如果不结合具题目,单纯的介绍原理估计很多同学会越看越懵!

  3. 最后,又可以出一道面试题了,就是纯完全背包,要求先用二维dp数组实现,然后再用一维dp数组实现,最后在问,两个for循环的先后是否可以颠倒?为什么? 这个简单的完全背包问题,估计就可以难住不少候选人了。

Reference: 代码随想录 (programmercarl.com)

本篇学习时间:90分钟。


LeetCode518. 零钱兑换

链接:518. 零钱兑换 II - 力扣(LeetCode)

1. 思路

这是一道典型的背包问题,一看到钱币数量不限,就知道这是一个完全背包。

但本题和纯完全背包不一样,纯完全背包是凑成背包最大价值是多少,而本题是要求凑成总金额的物品组合个数!

注意题目描述中是凑成总金额的硬币组合数,为什么强调是组合数呢?

例如示例一:

5 = 2 + 2 + 1

5 = 2 + 1 + 2

这是一种组合,都是 2 2 1。

如果问的是排列数,那么上面就是两种排列了。

组合不强调元素之间的顺序,排列强调元素之间的顺序。 其实这一点我们在讲解回溯算法专题的时候就讲过了哈。

那我为什么要介绍这些呢,因为这和下文讲解遍历顺序息息相关!

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

dp[j]:凑成总金额j的货币组合数为dp[j]

1.2 确定递推公式

dp[j] 就是所有的dp[j - coins[i]](考虑coins[i]的情况)相加。

所以递推公式:dp[j] += dp[j - coins[i]];

这个递推公式大家应该不陌生了,我在讲解01背包题目的时候在这篇494. 目标和 (opens new window)中就讲解了,求装满背包有几种方法,

公式都是:dp[j] += dp[j - nums[i]];

回顾 LeetCode494.目标和 的推导:

有哪些来源可以推出dp[j]呢?

只要搞到nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法。

例如:dp[j],j 为5,

  • 已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 容量为5的背包。
  • 已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 容量为5的背包。
  • 已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 容量为5的背包
  • 已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 容量为5的背包
  • 已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 容量为5的背包

那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。所以求组合类问题的公式,都是类似这种:

 dp[j] += dp[j - nums[i]]

这个公式在后面在讲解背包解决排列组合问题的时候还会用到!

1.3 dp数组如何初始化

  • dp[0]

    首先dp[0]一定要为1,dp[0] = 1是 递归公式的基础。如果dp[0] = 0 的话,后面所有推导出来的值都是0了。

    那么 dp[0] = 1 有没有含义,其实既可以说 凑成总金额0的货币组合数为1,也可以说 凑成总金额0的货币组合数为0,好像都没有毛病。

    但题目描述中,也没明确说 amount = 0 的情况,结果应该是多少。这里我认为题目描述还是要说明一下,因为后台测试数据是默认,amount = 0 的情况,组合数为1的;

  • 非零下标dp[ j ]

    下标非0的dp[j]初始化为0,这样累计加dp[j - coins[i]]的时候才不会影响真正的dp[j]。

1.4 确定遍历顺序

本题中我们是外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),还是外层for遍历背包(金钱总额),内层for循环遍历物品(钱币)呢?

我在**动态规划:关于完全背包,你该了解这些! (opens new window)**中讲解了完全背包的两个for循环的先后顺序都是可以的。

但本题就不行了!

因为纯完全背包求得装满背包的最大价值是多少,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!而本题要求凑成总和的组合数,元素之间明确要求没有顺序。所以纯完全背包是能凑成总和就行,不用管怎么凑的。本题是求凑出来的方案个数,且每个方案个数是为组合数。那么本题,两个for循环的先后顺序可就有说法了。

情况1:

外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)—— 组合数

for (int i = 0; i < coins.size(); i++) { // 遍历物品
    for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
        dp[j] += dp[j - coins[i]];
    }
}

假设题意:amount = 5;coins = [ 1,2,5 ]

情况1下的dp数组状态:

 算法训练Day44 动态规划专题- 背包问题 | 完全背包基础知识;LeetCode518. 零钱兑换(装满背包有多少种方法,组合数);377.组合总和IV(装满背包有多少种方法,排列数)_第6张图片

初始化 dp[0] = 1 ;非零下标:dp[j] = 0

  • i = 0 时:
    • dp[1] = dp[1] + dp[0] = 0+1 = 1;放的是 [1]

    • dp[2] = dp[2] + dp[1] = 0+1 = 1; 放的是[1,1]

      (其中dp[2]是代表之前遍历上一个i的时候的方案,然后加上dp[1]意思是,组成dp[1]的方案,额外加上一个1组成新的方案)

    • dp[3] = dp[3] + dp[2] = 0 +1 = 1;放的是[1,1,1]

    • dp[4] = dp[4] + dp[3] = 0+1 = 1;放的是[1,1,1,1]

    • dp[5] = dp[5] + dp[4] = 0+1 = 1;放的是[1,1,1,1,1]

  • i = 1 时:
    • dp[2] = dp[2] + dp[0] = 1+1=2;放的是[1,1],[2]
    • dp[3] = dp[3] + dp[1] = 1+1 = 2;放的是[1,1,1],[1,2]
    • dp[4] = dp[4] + dp[2] = 1+2 = 3; 放的是[1,1,1,1],[1,1,2],[2,2]
    • dp[5] = dp[5] + dp[3] = 1+2 = 3;放的是[1,1,1,1,1],[1,1,1,2],[1,2,1]
  • i = 2 时
    • dp[5] = dp[5] + dp[0] = 3+1 =4;放的是[1,1,1,1,1],[1,1,1,2],[1,2,1],[5]

因为物品i 是一个个遍历的,放了1再放2,不会出现顺序不同但是元素相同的组合,这种遍历顺序中dp[j]计算的是组合数。

情况2:

外层for循环遍历背包(金钱总额),内层for遍历物品(钱币)—— 排列数

for (int j = 0; j <= amount; j++) { // 遍历背包容量
    for (int i = 0; i < coins.size(); i++) { // 遍历物品
        if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
    }
}

假设题意:amount = 5;coins = [ 1,2,5 ]

情况2下的dp数组状态:

算法训练Day44 动态规划专题- 背包问题 | 完全背包基础知识;LeetCode518. 零钱兑换(装满背包有多少种方法,组合数);377.组合总和IV(装满背包有多少种方法,排列数)_第7张图片

初始化 dp[0] = 1 ;非零下标:dp[j] = 0

  • j = 0 时:dp[0] = 1
  • j = 1 时:
    • i = 0:dp[1] = dp[1] + dp[0] = 0+1 =1; 放的是[1]
    • i = 1: j < coins[i];无
    • i = 2: j < coins[i];无
  • j = 2 时:
    • i =0 : dp[2] = dp[2] + dp[1] = 0+1 = 1; 放的是[1,1]
    • i = 1: dp[2] = dp[2] + dp[0] = 1+1 = 2 ;放的是[1,1],[2]
    • i = 2: j < coins[i];无
  • j = 3 时:
    • i = 0: dp[3] = dp[3] + dp[2] = 0+2 = 2;放的是[1,1,1],[2,1]
    • i = 1:dp[3] = dp[3] + dp[1] = 2+1 = 3; 放的是[1,1,1],[2,1],[1,2]
    • i = 2: j < coins[i];无
  • j = 4 时:
    • i = 0:dp[4] = dp[4] + dp[3] = 0+3 = 3; 放的是[1,1,1,1],[2,1,1],[1,2,1]
    • i = 1:dp[4] = dp[4] + dp[2] = 3+2 = 5;放的是[1,1,1,1],[2,1,1],[1,2,1],[1,1,2],[2,2]
    • i = 2: j < coins[i];无
  • j = 5时:
    • i = 0: dp[5] = dp[5] + dp[4] = 0+5 = 5;放的是[1,1,1,1,1],[2,1,1,1],[1,2,1,1],[1,1,2,1],[2,2,1]
    • i = 1:dp[5] = dp[5] + dp[3] = 5+3 = 8; 放的是[1,1,1,1,1],[2,1,1,1],[1,2,1,1],[1,1,2,1],[2,2,1], [1,1,1,2],[2,1,2**],[1,2**,2**]**
    • i = 2:dp[5] = dp[5] + dp[0] = 8+1 =9;放的是[1,1,1,1,1],[2,1,1,1],[1,2,1,1],[1,1,2,1],[2,2,1], [1,1,1,2],[2,1,2**],[1,2**,2**],[5]**

背包容量中的每个值都是经过每个物品计算的,会包含元素相同但是顺序不同的情况,此时dp[j]中算出来的就是排列数。

1.5 举例推导dp数组

输入: amount = 5, coins = [1, 2, 5] ,dp状态图如下:

算法训练Day44 动态规划专题- 背包问题 | 完全背包基础知识;LeetCode518. 零钱兑换(装满背包有多少种方法,组合数);377.组合总和IV(装满背包有多少种方法,排列数)_第8张图片

最后红色框dp[amount]为最终结果。

2. 代码实现

# 动态规划-背包问题-完全背包应用
# time:O(N*amount);space:O(amount)
# step1. 确定dp[j]含义:装满容量为j的背包有dp[j]种方法
# step2. 递推公式: dp[j] += dp[j-coins[i]]
# step3. 初始化 dp[0] = 1;其余dp[j] = 0
# step4. 遍历顺序 因为是完全背包,所以背包容量正序遍历;
#        又因为是求组合问题,所以先遍历物品,再遍历背包
# step5. 打印dp数组
class Solution(object):
    def change(self, amount, coins):
        """
        :type amount: int
        :type coins: List[int]
        :rtype: int
        """
        dp = [0]*(amount+1)
        dp[0] = 1
				# 遍历物品
        for i in range(len(coins)):
						# 遍历背包
            for j in range(amount+1):
                if j >= coins[i]:
                    dp[j] += dp[j-coins[i]]
        return dp[amount]

3. 复杂度分析

  • 时间复杂度:O(N*amount);

    其中N为物品个数,amount为背包容量,两层for loop需要遍历的;

  • 空间复杂度:O(amount);

    其中amount为背包容量,dp数组的大小为amount。

4. 思考与收获

  1. 本题的递推公式,其实我们在**494. 目标和 (opens new window)**中就已经讲过了,而难点在于遍历顺序!

  2. 在求装满背包有几种方案的时候,认清遍历顺序是非常关键的。

    如果求组合数就是外层for循环遍历物品,内层for遍历背包

    如果求排列数就是外层for遍历背包,内层for循环遍历物品

Reference:代码随想录 (programmercarl.com)

本题学习时间:90分钟。


LeetCode377.组合总和IV

链接:377. 组合总和 Ⅳ - 力扣(LeetCode)

1. 思路

本题题目描述说是求组合,但又说是可以元素相同顺序不同的组合算两个组合,其实就是求排列!

弄清什么是组合,什么是排列很重要。组合不强调顺序,(1,5)和(5,1)是同一个组合。排列强调顺序,(1,5)和(5,1)是两个不同的排列。

大家在公众号里学习回溯算法专题的时候,一定做过这两道题目**回溯算法:39.组合总和 (opens new window)回溯算法:40.组合总和II (opens new window)**会感觉这两题和本题很像!

但其本质是本题求的是排列总和,而且仅仅是求排列总和的个数,并不是把所有的排列都列出来。如果本题要把排列都列出来的话,只能使用回溯算法爆搜

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

dp[i]: 凑成目标正整数为i的排列个数为dp[i]

1.2 确定递推公式

dp[i](考虑nums[j])可以由 dp[i - nums[j]](不考虑nums[j]) 推导出来。

因为只要得到nums[j],排列个数dp[i - nums[j]],就是dp[i]的一部分。

求装满背包有几种方法,递推公式一般都是dp[i] += dp[i - nums[j]];

1.3 dp数组如何初始化

因为递推公式dp[i] += dp[i - nums[j]]的缘故,dp[0]要初始化为1,这样递归其他dp[i]的时候才会有数值基础。

至于dp[0] = 1 有没有意义呢?

其实没有意义,所以我也不去强行解释它的意义了,因为题目中也说了:给定目标值是正整数! 所以dp[0] = 1是没有意义的,仅仅是为了推导递推公式。

至于非0下标的dp[i]应该初始为多少呢?

初始化为0,这样才不会影响dp[i]累加所有的dp[i - nums[j]]。

1.4 确定遍历顺序

个数可以不限使用,说明这是一个完全背包。得到的集合是排列,说明需要考虑元素之间的顺序。

本题要求的是排列,那么这个for循环嵌套的顺序可以有说法了。

在**动态规划:518.零钱兑换II (opens new window)**中就已经讲过了。

  • 如果求组合数就是外层for循环遍历物品,内层for遍历背包
  • 如果求排列数就是外层for遍历背包,内层for循环遍历物品

如果把遍历nums(物品)放在外循环,遍历target的作为内循环的话,举一个例子:计算dp[4]的时候,结果集只有 {1,3} 这样的集合,不会有{3,1}这样的集合,因为nums遍历放在外层,3只能出现在1后面!

所以本题遍历顺序最终遍历顺序:target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历

1.5 举例推导dp数组

我们再来用示例中的例子推导一下:

算法训练Day44 动态规划专题- 背包问题 | 完全背包基础知识;LeetCode518. 零钱兑换(装满背包有多少种方法,组合数);377.组合总和IV(装满背包有多少种方法,排列数)_第9张图片 

如果代码运行处的结果不是想要的结果,就把dp[i]都打出来,看看和我们推导的一不一样。

2. 代码实现

# 动态规划-背包问题-完全背包应用
# time:O(N*target);space:O(target)
# step1. dp[j]含义:组成总和为j的数,有dp[j]种排列方式
# step2. 递推公式:dp[j] += dp[j-nums[i]]
# step3. 初始化 dp[0] = 1;dp[j] =0
# step4. 遍历顺序,先背包容量(target);后物品
# step5. 打印dp
class Solution(object):
    def combinationSum4(self, nums, target):
        """
        :type nums: List[int]
        :type target: int
        :rtype: int
        """
        dp = [0]*(target+1)
        dp[0] = 1
        # 先遍历背包容量
        for j in range(target+1):
            # 再遍历物品
            for i in range(len(nums)):
                if j >= nums[i]:
                    dp[j] += dp[j-nums[i]]
        return dp[target]

3. 复杂度分析

  • 时间复杂度:O(N*target);

    其中N为物品个数,target为背包容量,两层for loop需要遍历的;

  • 空间复杂度:O(target);

    其中target为背包容量,dp数组的大小为target。

4. 思考与收获

  1. C++测试用例有两个数相加超过int的数据,所以需要在if里加上dp[i] < INT_MAX - dp[i - num]。
  2. **求装满背包有几种方法,递归公式都是一样的,没有什么差别,但关键在于遍历顺序!本题与动态规划:518.零钱兑换II (opens new window)**就是一个鲜明的对比,一个是求排列,一个是求组合,遍历顺序完全不同;
  3. 如果对遍历顺序没有深度理解的话,做这种完全背包的题目会很懵逼,即使题目刷过了可能也不太清楚具体是怎么过的,此时大家应该对动态规划中的遍历顺序又有更深的理解了。

Reference:代码随想录 (programmercarl.com)

本题学习时间:30分钟。


本篇学习时间为4小时,总结字数9000+;本篇先介绍了完全背包的基础知识,和01背包不同的是遍历顺序背包是正序,而且纯完全背包的for loop可以颠倒;然后LeetCode518. 零钱兑换II 是装满背包的方法数目,求组合;LeetCode377.组合总和IV也是装满背包的方法数目,但是是求排列。(求推荐!)

你可能感兴趣的:(代码随想录训练营,算法,动态规划,leetcode,python,职场和发展)