Bootstrap

代码随想录D39-42 动态规划07-09 Python

目录

多重背包问题介绍和案例

56. 携带矿石资源(第八期模拟笔试)

打家劫舍系列 

198. 打家劫舍

213. 打家劫舍 II

337. 打家劫舍 III 

普通递归:

记忆化递归:

树状结构的动态规划

买卖股票系列 

121. 买卖股票的最佳时机

0. 买卖股票1:只允许一次买卖

122. 买卖股票的最佳时机 II

0. 买卖股票2:允许多次的买卖

123. 买卖股票的最佳时机 III

0. 买卖股票3:最多进行两次买卖,交易不能重合

5. 实现 不同状态之间的互相影响

188. 买卖股票的最佳时机 IV

0. 买卖股票3:最多进行k次买卖,交易不能重合

309. 买卖股票的最佳时机含冷冻期

0. 买卖股票冷冻期:卖出的第二天不能购买股票

714. 买卖股票的最佳时机含手续费

0. 买卖股票带手续费:多次交易,卖出扣费


多重背包问题介绍和案例

有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。

多重背包和01背包是非常像的, 为什么和01背包像呢?每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。

因此,熟练掌握0-1背包,就能简单解决多重背包问题。这里对0-1背包进行一个复习,然后进入下一个阶段。

56. 携带矿石资源(第八期模拟笔试)

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

同 0 - 1 背包,dp[i][j] 表示使用第0 - i个物品装满容量为j的背包能取得的价值最多是多少

2. 状态转移方程

max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])

3. dp数组初始化

对第一行进行物品[0]价值的初始化,其他做全0初始化

4. 确定遍历顺序

一维dp时,背包容量需反向遍历,防止重复取物品

5. 实现

有两种处理方式,一种是按照思维逻辑,处理好输入,再运行0-1背包


def transform(weight_list, value_list, num_list):
    
    weights, values = list(), list()
    for idx, num in enumerate(num_list):
        for _ in range(num):
            weights.append(weight_list[idx])
            values.append(value_list[idx])
    
    length = len(weights)
    
    return weights, values, length
    

while True:
    
    try:
        target, n = list(map(int, input().split(' ')))
        weight_list = list(map(int, input().split()))
        value_list  = list(map(int, input().split()))
        num_list = list(map(int, input().split()))
        
        
        weight_list, value_list, n = transform(weight_list, value_list, num_list)
            
        dp = [0] * (target + 1)
        
        # print(dp)
        for i in range(n):
            for j in range(target, weight_list[i] - 1, -1):
                dp[j] = max(dp[j], dp[j - weight_list[i]] + value_list[i])
                    
        print(dp[target])
        
        
    except EOFError:
        
        break

另一种是直接在遍历过程中加入数量:


while True:
    
    try:
        C, N = input().split(" ")
        C, N = int(C), int(N)
        
        # value数组需要判断一下非空不然过不了
        weights = [int(x) for x in input().split(" ")]
        values = [int(x) for x in input().split(" ") if x]
        nums = [int(x) for x in input().split(" ")]
        
        dp = [0] * (C + 1)
        
        for i in range(N):
            # 遍历背包容量
            for j in range(C, weights[i] - 1, -1):
                for k in range(1, nums[i] + 1):
                    # 遍历 k,如果已经大于背包容量直接跳出循环
                    if k * weights[i] > j:
                        break
                    dp[j] = max(dp[j], dp[j - weights[i] * k] + values[i] * k) 
        print(dp[-1])
        
    except EOFError:
        
        break

打家劫舍系列 

198. 打家劫舍

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

走到下标[i] 能偷到的最大价值。

2. 状态转移方程

得到dp[i]的值,可以粗略归纳为偷 i 和不偷 i,即有dp[i - 2] (-1 不能偷) 和 dp[i - 1]。这里的思维点是,dp[i] 表示的是考虑到[i]位置时我们能偷到的最大价值。以最大价值来考虑,就不需要关注之前是跳几部取的,而是看状态转移规则就行了。 

3. dp数组初始化

创建dp时多加一个边界位置,全0初始化即可;取nums时注意索引

4. 确定遍历顺序

正常顺序遍历

5. 实现
class Solution:
    def rob(self, nums: List[int]) -> int:
        
        n = len(nums)
        dp = [0] * (n + 1)

        for i in range(1, n + 1):
            dp[i] = max(dp[i - 1], dp[i - 2] + nums[i - 1])

        return dp[n]

213. 打家劫舍 II

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

走到下标[i] 能偷到的最大价值。但是数组变成了环形数组。

2. 状态转移方程

和打家劫舍1的转移方程一致

3. dp数组初始化

创建dp时多加一个边界位置,全0初始化即可;取nums时注意索引

4. 确定遍历顺序

这个问题看似是环形数组,但实际上和环形数组的顺序关联不大。只需避免同时选头尾的问题即可。将输入数组拆分成[: -1]和[1: ]两个数组,使用dp获取每一个的最大值再进行比较即可。

5. 实现
class Solution:
    def rob(self, nums: List[int]) -> int:
        
        if len(nums) == 1:
            return nums[0]

        nums_1 = nums[: -1]
        nums_2 = nums[1: ]

        nums_list = [nums_1, nums_2]

        max_value = 0
        for num_list in nums_list:

            dp = [0] * (len(num_list) + 1)

            for i in range(1, len(num_list) + 1):
                dp[i] = max(dp[i - 1], dp[i - 2] + num_list[i - 1])
            
            value = dp[len(num_list)]

            if value > max_value:
                max_value = value

        return max_value

337. 打家劫舍 III

为了认识树形结构dp的特点,我们首先从暴力解法入手。 

普通递归:

单步逻辑:

对于一个子二叉树,设置偷父节点和不偷父节点两个选择,之后,隔一层取一次,比较在子树上取父节点和不取父节点那种方案收益更大。

输入输出:

输入root,返回两个策略中偷到的更多的值

终止条件:

node 不存在时,退出并返回0值;node 是叶子结点时,返回节点值

实现:

代码逻辑非常直观,可以看到暴力搜索过程相当于模拟了全部取值的组合,结果正确但超时

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def rob(self, root: Optional[TreeNode]) -> int:
        
        if root is None:
            return 0
        if root.left is None and root.right is None:
            return root.val

        val_1 = root.val
        if root.left:
            val_1 += self.rob(root.left.left) + self.rob(root.left.right)
        if root.right:
            val_1 += self.rob(root.right.left) + self.rob(root.right.right)
        
        val_2 = self.rob(root.left) + self.rob(root.right)

        return max(val_1, val_2)

记忆化递归:

上面的递归方法在测试例上只有两个没有通过,能不能通过空间换取时间呢?这就可以使用记忆化递归来解决。假设已经确定从某个节点出发的路径会被多次访问,那么我们可以直接讲这个节点的输出记录下来。新增终止条件:当访问到已经计算过的节点时,直接返回记录的返回值。使用python的字典可以很容易地进行优化。

实现:
# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    memory = dict()

    def rob(self, root: Optional[TreeNode]) -> int:

        if root is None:
            return 0
        if root.left is None and root.right is None:
            return root.val
        if self.memory.get(root) is not None:
            return self.memory.get(root)

        val_1 = root.val
        if root.left:
            val_1 += self.rob(root.left.left) + self.rob(root.left.right)
        if root.right:
            val_1 += self.rob(root.right.left) + self.rob(root.right.right)
        
        val_2 = self.rob(root.left) + self.rob(root.right)
        self.memory[root] = max(val_1, val_2)

        return max(val_1, val_2)

树状结构的动态规划

清楚两个递归遍历方法之后,可以来入门树形dp的思维和解法了。在树形dp中,算法架构依然是基于递归的,只是此前的递归中只关注计算结果,并没有记录每个节点的取值与否。

动态规划是使用状态转移容器来记录状态的变化,这里可以使用一个长度为2的数组,记录当前节点偷与不偷所得到的的最大金钱。

想象成每个节点上挂一个一维len == 2 的dp数组,dp[0] 表示不取这个点的最大取值, dp[1] 表示取这个点得到的最大取值。

1. 输入输出和终止条件

传入根节点,返回长度为2的dp数组。遇到空节点终止。递归函数返回长度为2的数组

2. 单层递归逻辑

如果是偷当前节点,那么左右孩子就不能偷,val1 = cur.val + left[0] + right[0]; (如果对下标含义不理解就再回顾一下dp数组的含义

如果不偷当前节点,那么左右孩子就可以偷,至于到底偷不偷一定是选一个最大的,所以:val2 = max(left[0], left[1]) + max(right[0], right[1]);

最后当前节点的状态就是{val2, val1}; 即:{不偷当前节点得到的最大金钱,偷当前节点得到的最大金钱}

3. 确定遍历顺序

对二叉树使用后序遍历,这样可以先处理左右节点,然后在对中节点进行处理。

5. 实现

递归的思路乍一看比较绕,但实际上单步的逻辑是非常清晰明了的。

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:

    def rob(self, root: Optional[TreeNode]) -> int:

        dp = self.traversal(root)

        return max(dp)

    def traversal(self, cur_node):

        if not cur_node:
            return (0, 0)

        left = self.traversal(cur_node.left)
        right = self.traversal(cur_node.right)
        ## 后序遍历 处理偷和不偷两种情况
        # 不偷当前节点 子节点也可能是被跳过的
        val_0 = max(left[0], left[1]) + max(right[0], right[1])
        # 偷当前节点
        val_1 = cur_node.val + left[0] + right[0]

        return (val_0, val_1)

买卖股票系列 

121. 买卖股票的最佳时机

0. 买卖股票1:只允许一次买卖

可以使用暴力搜索(嵌套循环);贪心(右侧最大 - 左侧最小)都可以解决

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        low = float("inf")
        result = 0
        for i in range(len(prices)):
            low = min(low, prices[i]) #取最左最小价格
            result = max(result, prices[i] - low) #直接取最大区间利润
        return result
1. dp数组以及下标的含义

一个shape = [n, 2] 的 dp数组。dp[i][0] 表示第i天买入股票所得最多现金,dp[i][1] 表示第i天卖出股票所得最多现金。

2. 状态转移方程

如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来

第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]

第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]

dp[i][0] = max(dp[i - 1][0], -prices[i])

如果第i天不持有股票即dp[i][1], 也可以由两个状态推出来

第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]

第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金即:prices[i] + dp[i - 1][0]

同样dp[i][1]取最大的:

dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0])

这里就和上一题呼应上了,通过遍历每一天里的持有和不持有状态,来计算最大收益。dp[i][0]的状态转移规则可以检查在最低点买入的机会(相反数取最大值),dp[i][1]则依赖这个基础来求最大收益值。

3. dp数组初始化

之所以设置 dp[i][0] 第i天持有股票所得最多现金,是因为当选择持有时,现金数额应该减去花费。 所以dp[0][0] 应该初始化成第一天股票价格负数。 

4. 确定遍历顺序

顺序遍历天数即可

5. 实现
class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        
        dp = [[0] * 2 for _ in range(len(prices))]
        dp[0][0] = -prices[0]

        for i in range(1, len(prices)):

            dp[i][0] = max(dp[i - 1][0], -prices[i])
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i])

        return dp[len(prices) - 1][1]

122. 买卖股票的最佳时机 II

0. 买卖股票2:允许多次的买卖

贪心仍然可以解决,因为处理的是单调问题。简单思路就是,今天有得赚,明天赚的少了,就赶紧卖出,参考代码

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

一个shape = [n, 2] 的 dp数组。dp[i][0] 表示第i天买入股票所得最多现金,dp[i][1] 表示第i天卖出股票所得最多现金。

2. 状态转移方程

由于本题中多了中途卖出的过程,这会影响到 dp[i][0] 位的判断。

也就是说,昨天卖出股票手上的余额 减去 今天的股价,如果大于不卖出一直持有的余额,说明昨天的交易会挣钱,那么今天就要买入。

dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i])

如果第i天卖出股票即dp[i][1], 也可以由两个状态推出来

第i-1天卖出股票手上的余额,对比今天卖出手中的余额,同样取最大的:

dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0])

我们通过维护每一天两个状态下手中的余额,来取使得两个状态下余额都是最大的情况,最后推导出收益。 需要注意:dp[i][1],表示的是第i天,买入股票的状态,并不是说一定要第i天买入股票。

3. dp数组初始化

之所以设置 dp[i][0] 第i天持有股票所得最多现金,是因为当选择持有时,现金数额应该减去花费。 所以dp[0][0] 应该初始化成第一天股票价格负数。 

4. 确定遍历顺序

顺序遍历天数即可

5. 实现
class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        
        
        dp = [[0] * 2 for _ in range(len(prices))]
        dp[0][0] = -prices[0]

        for i in range(1, len(prices)):
            ## 计算昨天卖出+今天买入 和一直持有的收益
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i])
            ## 计算昨天卖出和今天卖出的收益
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i])

            # print(dp)
        return dp[len(prices) - 1][1]

123. 买卖股票的最佳时机 III

0. 买卖股票3:最多进行两次买卖,交易不能重合

复杂点在于,至多买卖两次,这意味着可以买卖一次,可以买卖两次,也可以不买卖。

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

由于我们监控的是今天买入或卖出,手中实际的利润,那么这里的每一天中可能有5种状态

0. 无操作(可以不考虑)

1. 第一次买入

2. 第一次卖出

3. 第二次买入

4. 第二次卖出

dp数组的定义也就清楚了:dp[i][j]中 i表示第i天,j为 [0 - 4] 五个状态,dp[i][j]表示第i天状态j所剩最大现金。

2. 状态转移方程

达到 dp[i][1]状态,有两个具体操作:

操作一:第i天买入股票了,那么dp[i][1] = dp[i-1][0] - prices[i]

操作二:第i天没有操作,而是沿用前一天买入的状态,即:dp[i][1] = dp[i - 1][1]

选择:dp[i-1][0] - prices[i],和 dp[i - 1][1] 中更大的,理由同上一个问题一样,始终确保在任何状态下手上的利润最大化。

同理 dp[i][2]也有两个操作:

操作一:第i天卖出股票了,那么dp[i][2] = dp[i - 1][1] + prices[i]

操作二:第i天没有操作,沿用前一天卖出股票的状态,即:dp[i][2] = dp[i - 1][2]

所以dp[i][2] = max(dp[i - 1][1] + prices[i], dp[i - 1][2])

继续 dp[i][3]也有两个操作:

操作一:第i天第二次买入股票了,那么dp[i][3] = dp[i-1][2] - prices[i] 此时上一支一定卖出了

操作二:第i天没有操作,而是沿用前一天买入的状态,即:dp[i][3] = dp[i - 1][3]

最后 dp[i][4]也有两个操作:

操作一:第i天卖出股票了,那么dp[i][4] = dp[i - 1][3] + prices[i]

操作二:第i天没有操作,沿用前一天卖出股票的状态,即:dp[i][4] = dp[i - 1][4]

所以dp[i][4] = max(dp[i - 1][3] + prices[i], dp[i - 1][4])

3. dp数组初始化

示例四表示如果只有一天,那么即使操作了也是收益0的状态。

因此,我们可以将 dp[0][0] 初始为 0;将 dp[0][1] 初始成 -price[0];dp[0][2] 初始成0;dp[0][3] 同样初始为 -price[0]。相当于一天内全部操作了一遍,符合边界条件。

4. 确定遍历顺序

顺序遍历天数即可

5. 实现 不同状态之间的互相影响

可以发现,这个题目以及上一题都描述了状态之间的互相影响,也是题目的考察点和难点。实际上,下面的代码也可以通过一维dp来解决。

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        
        dp = [[0] * 5 for _ in range(len(prices))]
        dp[0][1] = dp[0][3] = -prices[0]

        for i in range(1, len(prices)):
            dp[i][0] = dp[i - 1][0]
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i])
            dp[i][2] = max(dp[i - 1][2], dp[i - 1][1] + prices[i])
            dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] - prices[i])
            dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i])

        # print(dp)
        return dp[-1][-1]

188. 买卖股票的最佳时机 IV

0. 买卖股票3:最多进行k次买卖,交易不能重合
1. dp数组以及下标的含义

参考上题的两次操作:

0. 无操作(可以不考虑)

1. 第一次买入

2. 第一次卖出

3. 第二次买入

4. 第二次卖出

......

2k. 第k次买入

2k + 1. 第k次卖出

dp[i][j]中 i表示第i天,j为 [0: 2k + 1] 个状态,dp[i][j]表示第i天状态j所剩最大现金。

2. 状态转移方程

根据上一题我们已经能够判断每个维度的关联和行为。

首先0维度只做初始化使用,为了简便泛化代码,我们保留这一行

奇数维度全部在记录保持或买入的状态

偶数维度全部在记录保持或卖出的状态

我们将原有的i天降维成一维,但是在输入中添加k次交易。2次交易实际状态维度是5维,因此k次交易的状态维度就是2k + 1维

3. dp数组初始化

示例四表示如果只有一天,那么即使操作了也是收益0的状态。

将 dp[0][奇数列] 初始成 -price[0]

4. 确定遍历顺序

顺序遍历天数即可

5. 实现 

 在每一天对k做一次循环,实现泛化

class Solution:
    def maxProfit(self, k: int, prices: List[int]) -> int:
        
        n = 2 * k + 1
        dp = [0] * n

        for i in range(n):
            if i % 2 != 0:
                dp[i] = -prices[0]

        for i in range(1, len(prices)):
            for j in range(n):
                
                if j == 0:
                    continue

                elif j % 2 != 0:
                    dp[j] = max(dp[j], dp[j - 1] - prices[i])

                elif j % 2 == 0:
                    dp[j] = max(dp[j], dp[j - 1] + prices[i])
        
        # print(dp)
        return dp[-1]

309. 买卖股票的最佳时机含冷冻期

0. 买卖股票冷冻期:卖出的第二天不能购买股票

已经熟悉了持有和售出这类不同状态对于dp影响的案例,这里添加了冷冻期限制,但购买次数不限

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

一个shape = [n, 2] 的 dp数组。dp[i][0] 表示第i天买入股票所得最多现金,dp[i][1] 表示第i天卖出股票所得最多现金。

2. 状态转移方程

多了 中途卖出+冷冻期 的过程,这会影响状态判断。

和上面一样,不要考虑哪一天买入卖出,只考虑状态,我们列举一下每一天可能的情况:

1. 持有状态;2. 卖出状态(已经度过冷冻期但不持有);3. 卖出状态(卖出当天);4. 卖出状态(冷冻期内)。

这里度过冷冻期但不持有,最好和冷冻期内区分对待,这是为了方便后续的状态转移分析。

现在来看每个状态之间的互相影响:

第一个i天是持有状态可能的来源

1. 前一天就持有 dp[i][0] = dp[i - 1][0];

2. 前一天是冷冻期,今天买入 dp[i][0] = dp[i - 1][3] - price[i]

3. 前一天不是冷冻期,但一直不持有,今天买入 dp[i][0] = dp[i - 1][1] - price[i]

这里有一个坑点,需要先比较2和3谁的值更大,用更大的值减价格

dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3] - prices[i], dp[i - 1][1]) - prices[i])

第二个卖出状态(已经度过冷冻期但不持有)

1. 前一天是卖出,不处于冷冻期内 dp[i][1] = dp[i - 1][0]

2. 前一天就是卖出,处于冷冻期内 dp[i][1] = dp[i - 1][3] 

dp[i][1] = max(dp[i - 1][1], dp[i - 1][3])

第三个, 卖出状态(卖出当天)

前一天肯定是持有的

dp[i][2] = dp[i - 1][0] + prices[i]

第四个, 卖出状态(冷冻期内)

前一天肯定刚卖出

dp[i][3] = dp[i - 1][2]
3. dp数组初始化

对第一天进行持有的初始化,其他位置全0即可

4. 确定遍历顺序

顺序遍历天数即可

5. 实现
class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        
        dp = [[0] * 4 for _ in range(len(prices))]
        dp[0][0] = -prices[0]
        print(dp)

        for i in range(1, len(prices)):
            dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3], dp[i - 1][1]) - prices[i])
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][3])
            dp[i][2] = dp[i - 1][0] + prices[i]
            dp[i][3] = dp[i - 1][2]

        # print(dp)
        return max(dp[-1][3], dp[-1][1], dp[-1][2])

714. 买卖股票的最佳时机含手续费

0. 买卖股票带手续费:多次交易,卖出扣费
状态转移方程

持有状态不受手续费影响,一种是前一天就持有,另一种是今天持有。

dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i])

如果第i天卖出股票即dp[i][1], 也可以由两个状态推出来

第i-1天卖出股票手上的余额,对比今天卖出手中的余额 扣除手续费,取最大的:

dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0])
实现:
class Solution:
    def maxProfit(self, prices: List[int], fee: int) -> int:
        
        n =len(prices)
        dp = [[0] * 2 for _ in range(n)]
        dp[0][0] = -prices[0]

        for i in range(1, n):
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i])
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i] - fee)

        # print(dp)
        return dp[-1][-1]

;