【代码随想录】d44-动态规划-part04-python

1.01 背包

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大
暴力法:
每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是 o ( 2 n ) o(2^n) o(2n),这里的n表示物品数量。
所以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!
用以下例子进行分析
【代码随想录】d44-动态规划-part04-python_第1张图片

1.1 二维dp数组01背包

1. 确定dp数组以及下标的含义
对于背包问题,有一种写法, 是使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是dp[i][j]
如下图所示:
【代码随想录】d44-动态规划-part04-python_第2张图片
2. 确定递推公式
再回顾一下dp[i][j]的含义:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
那么可以有两个方向推出来dp[i][j],

  • 不放物品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[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。如图:
【代码随想录】d44-动态规划-part04-python_第3张图片状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。
dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值
那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。
当j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。
此时dp数组初始化情况如图所示:
【代码随想录】d44-动态规划-part04-python_第4张图片
dp[0][j] 和 dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢?
其实从递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出dp[i][j] 是由左上方数值推导出来了,那么 其他下标初始为什么数值都可以,因为都会被覆盖。
初始-1,初始-2,初始100,都可以
但只不过一开始就统一把dp数组统一初始为0,更方便一些。
如图:
【代码随想录】d44-动态规划-part04-python_第5张图片
4. 确定遍历顺序
根据上图可以看出,是有两个遍历的维度:物品与背包重量
那么应该先遍历哪一个维度呢?
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的。
dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正上方向),那么先遍历物品,再遍历背包的过程如图所示:
【代码随想录】d44-动态规划-part04-python_第6张图片
再来看看先遍历背包,再遍历物品呢,如图:
【代码随想录】d44-动态规划-part04-python_第7张图片虽然两个for循环遍历的次序不同,但是dp[i][j]所需要的数据就是左上角,根本不影响dp[i][j]公式的推导!
但先遍历物品再遍历背包这个顺序更好理解。
5.举例推导dp数组
来看一下对应的dp数组的数值,如图:
【代码随想录】d44-动态规划-part04-python_第8张图片
【代码随想录】d44-动态规划-part04-python_第9张图片
代码:

def test_2_wei_bag_problem1():
    weight = [1, 3, 4]
    value = [15, 20, 30]
    bagweight = 4

    # 二维数组
    dp = [[0] * (bagweight + 1) for _ in range(len(weight))]

    # 初始化
    for j in range(weight[0], bagweight + 1):  #重量小于weight[0]的背包放不进物品0,价值为0,能放进物品0的背包价值为value[0]
        dp[0][j] = value[0]

    # weight数组的大小就是物品个数
    for i in range(1, len(weight)):  # 遍历物品
        for j in range(bagweight + 1):  # 遍历背包容量
            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])

    print(dp[len(weight) - 1][bagweight])
test_2_wei_bag_problem1()

1.2 一维dp数组01背包

在使用二维数组的时候,递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);
与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。
1. 确定dp数组以及下标的含义
在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
2.一维dp数组的递推公式
dp[j]为 容量为j的背包所背的最大价值,那么如何推导dp[j]呢?
dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。
dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j])
此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值,

所以递归公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了。
3.一维dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。
那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?
看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。
这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。
4.一维dp数组遍历顺序
结论:先遍历物品,再遍历背包,且背包重量要从大到小,倒序遍历
问题1:为什么背包重量要倒序遍历?
列表后面的值需要通过与上一层遍历得到的前面的值比较确定
一维数组本质是保留的上层数据,然后计算本层的背包价值,如果正序遍历,就会使需要用到的上层数据值被刷新,导致计算错误
举例1:
假设目前有背包容量为10,可以装的最大价值, 记为dp(10)
即将进来的物品重量为6。价值为9。
那么此时可以选择装该物品或者不装该物品。

  • 如果不装该物品,显然背包容量无变化,这里对应二维数组,其实就是取该格子上方的格子复制下来,就是所说的滚动下来,直接dp[10] = dp[10],右边的dp[10]是上一轮记录的,也就是对应二维数组里上一层的值,而左边是新的dp[10],也就是对应二维数组里下一层的值。
  • 如果装该物品,则背包价值dp[10]= dp[10-6] +9 = dp[4] + 9 ,也就是dp[10] = dp[4] +9 ,这里的9显然就是新进来的物品的价值,dp[10]就是新记录的,对应二维数组里下一层的值,而这里的dp[4]是对应二维数组里上一层的值,通俗的来讲:你要找到上一层也就是上一状态下 背包容量为4时的能装的最大价值,用它来更新下一层的这一状态,也就是加入了价值为9的物品的新状态。

这时候如果是正序遍历会怎么样? dp[10] = dp[4] + 9 ,这个式子里的dp[4]就不再是上一层的了,因为你是正序啊,dp[4] 比dp[10]提前更新,那么此时程序已经没法读取到上一层的dp[4]了,当前的dp[4]已经被新更新的下一层的dp[4]覆盖掉了

举例2:
物品0的重量weight[0] = 1,价值value[0] = 15
如果正序遍历
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30 (dp[2 - weight[0]]=dp[1]是前面一行计算的结果15)
此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。

为什么倒序遍历,就可以保证物品只放入一次呢?
倒序就是先算dp[2]
dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp[2 - weight[0]]=dp[1]是初始化的0)
dp[1] = dp[1 - weight[0]] + value[0] = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

那么为什么二维dp数组历的时候不用倒序呢?
因为对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖!

问题2,能不能先遍历背包再遍历物品?
5.举例推导dp数组
一维dp,分别用物品0,物品1,物品2 来遍历背包,最终得到结果如下:
【代码随想录】d44-动态规划-part04-python_第10张图片
代码:

def test_1_wei_bag_problem():
    weight = [1, 3, 4]
    value = [15, 20, 30]
    bagWeight = 4

    # 初始化
    dp = [0] * (bagWeight + 1)
    for i in range(len(weight)):  # 遍历物品
        for j in range(bagWeight, weight[i] - 1, -1):  # 遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i])

    print(dp[bagWeight])
test_1_wei_bag_problem()

1.3 dfs(超时)

思路1:
使用深度优先搜索,找出所有可能的组合,再从这些组合中找出满足条件的结果,条件为:物品总重量小于等于背包重量

  1. 确定终止条件
    当物品总重量大于背包重量,就返回,开始回溯
    当物品总重量小于等于背包重量,就计算最大价值
  2. 树层遍历
    遍历原数组,通过startindex控制下一个分支的取值,避免出现重复组合,遍历过程中计算物品的价值
weight = [1, 3, 4]
value = [15, 20, 30]
bagweight = 4
ans = 0


def dfs(startindex,total,v):
    global ans
    if total > bagweight:  # 物品重量超过了背包重量,返回
        return
    if total <= bagweight:  # 物品重量小于等于背包重量,计算最大价值
        ans = max(ans,v)

    for i in range(startindex,len(weight)):
        v += value[i]  # 物品价值和
        total += weight[i]  # 物品重量和
        dfs(i+1,total,v)  # 递归,进入下一层,选第二个元素
        v -= value[i]  # 回溯,恢复之前加的价值
        total -= weight[i]  # 回溯,恢复之前加的重量

dfs(0,0,0)
print(ans)

2.416. 分割等和子集

2.1题目及讲解

给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

  • 输入:nums = [1,5,11,5]
  • 输出:true
  • 解释:数组可以分割成 [1, 5, 5] 和 [11] 。

示例 2:

  • 输入:nums = [1,2,3,5]
  • 输出:false
  • 解释:数组不能分割成两个元素和相等的子集。

提示:

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 100

题目链接/文章讲解:
https://programmercarl.com/0416.%E5%88%86%E5%89%B2%E7%AD%89%E5%92%8C%E5%AD%90%E9%9B%86.html
视频讲解:https://www.bilibili.com/video/BV1rt4y1N7jE

2.2代码实现

思路1:
使用深度优先搜索,递归去找恰好使target等于0的情况

  1. 确定终止条件
    当target小于0,返回false,开始回溯,找其他的分支
    当target等于0,返回True,表示找到了刚好使元素和等于target的情况
  2. 遍历数组,取其中一个元素1,然后递归取下一个元素2,同时target减去元素1的值,一直到target等于0或者小于0,小于0代表需要回溯
    方法一:dfs(超时)
nums = [1,5,11,5]


def dfs(startindex,target):
    if target == 0:  # 终止条件,当目标值为0,代表可以分割出来
        return True
    if target < 0:  # 终止条件,当目标值小于0,代表组合元素和超过了一半,无法分割出来
        return False
    for i in range(startindex,len(nums)):  # 遍历数组
        if dfs(i+1,target-nums[i]):  # 递归调用
            return True
    return False


def fun():
    total = sum(nums)
    if total%2 != 0:
        return False
    target = total// 2  # 总和的一般
    if nums[-1] > target:  # nums的最大值大于总和的一半,那么就没法分割
        return False
    return dfs(0,target)


print(fun())

思路2:
本题要求集合里能否出现总和为 sum / 2 的子集,对应到01背包

  • 背包的体积为sum / 2
  • 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
  • 背包如果正好装满,说明找到了总和为 sum / 2 的子集。
  • 背包中每一个元素是不可重复放入。
  1. 确定dp数组以及下标的含义
    01背包中,dp[j] 表示: 容量为j的背包,所背的物品价值最大可以为dp[j]。
    本题中每一个元素的数值既是重量,也是价值。
    套到本题,dp[j]表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j]。
    那么如果背包容量为target, dp[target]就是装满 背包之后的重量,所以 当 dp[target] == target 的时候,背包就装满了。
    装不满的情况,如下:
    拿输入数组 [1, 5, 11, 5],举例, dp[7] 只能等于 6,因为 只能放进 1 和 5。
    而dp[6] 就可以等于6了,放进1 和 5,那么dp[6] == 6,说明背包装满了。
  2. 确定递推公式
    01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    本题,相当于背包里放入数值,那么物品i的重量是nums[i],其价值也是nums[i]。
    所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
  3. dp数组初始化
    从dp[j]的定义来看,首先dp[0]一定是0。(背包容量为0,所以最大价值为0)
    如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。
    这样才能让dp数组在递推的过程中取得最大的价值,而不是被初始值覆盖了。
    本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。
  4. 确定遍历顺序
    如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!
  5. 举例推导dp数组
    dp[j]的数值一定是小于等于j的。
    如果dp[j] == j 说明,集合中的子集总和正好可以凑成总和j,理解这一点很重要。
    用例1,输入[1,5,11,5] 为例,如图:
    【代码随想录】d44-动态规划-part04-python_第11张图片
    最后dp[11] == 11,说明可以将这个数组分割成两个子集,使得两个子集的元素和相等
    方法二:动态规划
nums = [1,5,11,5]


def fun():
    total = sum(nums)
    if total%2 != 0:  # 总和为奇数,不能分割,直接返回False
        return False
    target = total// 2  # 总和的一半
    if max(nums) > target:  # nums的最大值大于总和的一半,那么就没法分割
        return False
    dp = [0]*(target+1)
    for i in range(len(nums)):
        # j指的是背包总量,倒序遍历,从最大装target开始,遍历到能装下nums[i],再往前就装不下了
        for j in range(target,nums[i]-1,-1):
            dp[j] = max(dp[j],dp[j-nums[i]]+nums[i])
    print(dp)
    return dp[target] == target


print(fun())


'''
dp[j]表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j](也是最大价值,本题最大价值和最大重量相等)。
target为背包目标重量,放进物品dp[target]为最大重量,当最大重量刚好等于target时代表背包装满了,小于target表示还有空余的重量,没装满
'''

你可能感兴趣的:(动态规划,python,算法)