动态规划题目总结

动态规划系列

正则表达式

(注意:不是子字符串查找)

第一步:普通字符串匹配

例如:对于输入的字符串text,是否与给定的pattern匹配
思路:
1、判断text与pattern的长度是否相等,不相等返回False,相等进入下一步。
2、遍历text和pattern的每个位置,遇到不相等的返回false
3、最后返回True
代码如下:

def isMatch(text, pattern):
    if len(text) != len(pattern):
        return False
    for i in range(len(text)):
        if text[i] != pattern[i]:
            return False
    return True      

将其改为递归形式:

def isMatch(text, pattern):
    if len(text) != len(pattern):
        return False
    if len(text)==0:
        return True
    else:
        return text[0] == pattern[0] and isMatch(text[1:], pattern[1:])

第二步加入通配符:.

这个时候len(text) 和 len(pattern) 是仍然需要相等的

def isMatch(text, pattern):
    if len(text) != len(pattern):
        return False
    if len(text)==0:
        return True
    else:
        return (text[0] == pattern[0] or pattern[0]=='.') and isMatch(text[1:], pattern[1:])

第三步加入通配符:*

a*b可以匹配b,ab,aab,aaab...
分为两类:匹配0次和匹配1次(匹配多次可以用递归处理)
匹配0次的子问题是isMatch(text, pattern[2:])
匹配1次(len(text)>0 and (text[0] == pattern[0] or pattern[0]=='.' ))的子问题是isMatch(text[1:], pattern)
这个时候len(text) 和 len(pattern) 可以不相等了

class Solution:
    def isMatch(self, s: str, p: str) -> bool:
        ## 递归结束条件
        """
        len(p)==0: 判断len(s)
        len(p)>0:
            len(p)>1 且 p[1]=='*':
                匹配0次: isMatch(s, p[2:])
                匹配1次:len(s)>0 and (s[0]==p[0] or p[0]=='.') and isMatch(s[1:],p)
            其他情况:
                匹配1次:len(s)>0 and (s[0]==p[0] or p[0]=='.') and isMatch(s[1:],p[1:])
        """
        if len(p)==0:
            if len(s)==0:
                return True 
            else:
                return False
        
        elif len(p) > 1 and p[1] == '*':
            return self.isMatch(s, p[2:]) or len(s)>0 and (s[0]==p[0] or p[0]=='.') and self.isMatch(s[1:],p)
        else:
            return len(s)>0 and (s[0]==p[0] or p[0]=='.') and self.isMatch(s[1:],p[1:])

递归调用的图示:

转换成动态规划问题

使用dp table来消除递归中的重叠子问题,降低复杂度。
如何看出可以使用动态规划的?
考虑调用过程中的状态转移过程
用i,j分别代表text和pattern的索引
我们知道text[i+2],pattern[j+2]是text[i],pattern[j]的子问题,其递归调用可能是:
text[i],pattern[j]->text[i+1],pattern[j+1]->text[i+2],pattern[j+2]
也可能是
text[i],pattern[j]->text[i],pattern[j+2]->text[i+1],pattern[j+2]->text[i+2],pattern[j+2]

class Solution:
    def isMatch(self, s: str, p: str) -> bool:
        text = s
        pattern = p
        lookup = {}
        def helper(i,j):
            if (i,j) in lookup:
                return lookup[(i,j)]
            if len(pattern[j:]) == 0:
                return True if len(text[i:])==0 else False
            if len(pattern[j:])>1 and pattern[j+1] == '*':
                ans = helper(i,j+2) or (len(text[i:])>0 and pattern[j] in ['.',text[i]] and helper(i+1,j))
            else:
                ans = len(text[i:])>0 and pattern[j] in ['.',text[i]] and helper(i+1,j+1)
            lookup[(i,j)] = ans 
            return ans
        return helper(0,0)

最长递增子序列

动态规划

首先要定义清楚 dp 数组的含义:dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度
状态转移:
已知dp[i],nums[i+1],如何计算dp[i+1]?
nums[0...i]数组中比nums[i+1]小的位置j,由于dp[j]表示以nums[j]结尾的最长递增子序列的长度,所以dp[i+1]=max(dp[i+1],dp[j]) (j=0...i)

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        if len(nums)<2:
            return len(nums)
        dp = [1 for i in range(len(nums))]
        for i in range(len(nums)):
            for j in range(0,i):
                if nums[j] < nums[i]:
                    dp[i] = max(dp[i],dp[j]+1)
            #print(dp)
        return max(dp)

至此,这道题就解决了,时间复杂度 O(N^2)。总结一下动态规划的设计流程:
首先明确 dp 数组所存数据的含义。这步很重要,如果不得当或者不够清晰,会阻碍之后的步骤。
然后根据 dp 数组的定义,运用数学归纳法的思想,假设 dp[0...i−1] 都已知,想办法求出 dp[i],一旦这一步完成,整个题目基本就解决了。
但如果无法完成这一步,很可能就是 dp 数组的定义不够恰当,需要重新定义 dp 数组的含义;或者可能是 dp 数组存储的信息还不够,不足以推出下一步的答案,需要把 dp 数组扩大成二维数组甚至三维数组。

二分查找

[10,9,2,5,3,7,101,18,5]
num=10 ,
dp[1] = [10]
num=9 ,
dp[1] = [10, 9]
num=2 ,
dp[1] = [10,9 2]
num=5 ,
dp[1] = [10,9 2],
dp[2] = [2,5]
num=3 ,
dp[1] = [10,9 2],
dp[2] = [2,5 3]
num=7 ,
dp[1] = [10,9 2],
dp[2] = [2,5 3],
dp[3] = [2,3,7]
num=101,
dp[1] = [10,9 2],
dp[2] = [2,5 3],
dp[3] = [2,3,7]
dp[4] = [2,3,7,101]
num=18,
dp[1] = [10,9 2],
dp[2] = [2,5 3],
dp[3] = [2,3,7]
dp[4] = [2,3,7,101 18]
num=5,
dp[1] = [10,9 2],
dp[2] = [2,5 3],
dp[3] = [2,3,7 5]
dp[4] = [2,3,7,101 18]

dp[1],dp[2],dp[3],dp[4]末尾元素所组成的array是递增序列(证明略),加入新数字时,使用二分查找更新array,最后array的长度即所求,算法的时间复杂度为:O(NlogN)

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        if len(nums) <2:
            return len(nums)
        sub_array = []
        for i in range(len(nums)):
            if i == 0:
                sub_array.append(nums[0])
            else:
                ## 把以下部分修改成二分查找
                if nums[i] > sub_array[-1]:
                    sub_array.append(nums[i])
                elif nums[i] < sub_array[0]:
                    sub_array[0] = nums[i]
                else:
                    for j in range(len(sub_array)):
                        if nums[i] < sub_array[j] and nums[i] > sub_array[j-1]:
                            sub_array[j] = nums[i]
            #print(i,nums[i],sub_array)
        return len(sub_array)

股票买卖问题

股票系列一共 6 道题:

LeetCode 121

贪心
只要记录前面的最小价格,将这个最小价格作为买入价格,然后将当前的价格作为售出价格,决定是否更新当前的最大收益。注意维护两个变量:前面的最小价格 和 当前的最大收益。

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        ans = 0
        for i,price in enumerate(prices):
            if i == 0:
                buy = price
            ans = max(price - buy, ans)
            buy = min(price, buy)
        return ans 

LeetCode 122

贪心
股价有升有落,需要找出所有的升区间,计算每个升区间的价格差(峰值减去谷值)作为收益,最后把所有升区间带来的收益求和就可以了。
对于升区间 [a, b, c, d],有 a <= b <= c <= d ,那么最大收益为 d - a。而 d - a = (d - c) + (c - b) + (b - a) ,因此每当访问到 prices[i] 比前一天价格高,即 prices[i] - prices[i-1] > 0,那么就把 prices[i] - prices[i-1] 添加到收益中。

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        if n<=1: return 0
        profit = 0
        for i in range(1, n):
            if prices[i] > prices[i-1]:
                profit += prices[i] - prices[i-1]
        return profit

(下面还提供了本题的 DP 解法)

二维 DP

每天都有三种动作:买入(buy)、卖出(sell)、无操作(rest)。

因为不限制交易次数,因此交易次数这个因素不影响题目,不必考虑。DP Table 是二维的,两个维度分别是天数(0,1,...,n-1)和是否持有股票(1 表持有,0 表不持有)。

状态转移方程
Case 1,今天我没有股票,有两种可能:

昨天我手上就没有股票,今天不做任何操作(rest);
昨天我手上有一只股票,今天按照时价卖掉了(sell),收获了一笔钱
Case 2,今天持有一只股票,有两种可能:

昨天我手上就有这只股票,今天不做任何操作(rest);
昨天我没有股票,今天按照时价买入一只(sell),花掉了一笔钱
综上,第 i 天的状态转移方程为:

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])
注意上面的转移方程只是对某一天而言的,要求出整个 DP Table 的状态,需要对 i 进行遍历。

边界状态
观察状态转移方程,第 i 天的状态只由第 i-1 天状态推导而来,因此边界状态只需要定义 i=0(也就是第一天)即可:

dp[0][0] = 0 # 第一天没有股票,说明没买没卖,获利为0
dp[0][1] = -prices[0] # 第一天持有股票,说明买入了,花掉一笔钱

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        if n<=1: return 0

        dp = [[None, None] for _ in range(n)]
        dp[0][0] = 0
        dp[0][1] = -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])

        return dp[-1][0]    # 返回最后一天且手上没有股票时的获利情况

LeetCode 309

这道题的在 LeetCode 122E 的基础上添加了冷冻期的要求,即每次 sell 之后要等一天才能继续交易。状态转移方程要做修改,如果第 i 天选择买入股票,状态要从第 i-2 的转移,而不是 i-1 (因为第 i-1 天是冷冻期)。另外,由于状态转移方程中出现了 dp[i-2] 推导 dp[i-1],因此状态边界除了考虑 i=0 天,还要加上 i=1 天的状态。Solution 如下:

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        if n<=1: return 0

        dp = [[None, None] for _ in range(n)]
        dp[0][0] = 0
        dp[0][1] = -prices[0]
        dp[1][0] = max(dp[0][0], dp[0][1]+prices[1])  # 昨天就没有 或者 昨天买入今天卖出
        dp[1][1] = max(dp[0][1], -prices[1])  # 昨天就有 或者 昨天没有而今天买入

        for i in range(2, 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-2][0]-prices[i])    # 买入股票时注意冷冻期
        return dp[-1][0]

LeetCode 714

这道题在 LeetCode 122E 的基础上添加了交易费的要求,可以理解为每次 sell 时要缴纳一定的费用。边界状态保持不变,状态转移方程需要做修改。Solution 如下:

class Solution:
    def maxProfit(self, prices: List[int], fee: int) -> int:
        n = len(prices)
        if n<=1: return 0

        dp = [[None, None] for _ in range(n)]
        dp[0][0] = 0
        dp[0][1] = -prices[0]

        for i in range(1, n):
            dp[i][0] = max(dp[i-1][0], dp[i-1][1]+prices[i]-fee)  # 卖出股票时注意要缴手续费
            dp[i][1] = max(dp[i-1][1], dp[i-1][0]-prices[i])

        return dp[-1][0]

三维 DP

LeetCode 123

题目约定最多交易次数 k=2,因此交易次数必须作为一个新的维度考虑进 DP Table 里,也就是说,这道题需要三维 DP 来解决。三个维度分别为:天数 i(i=0,1,...,n-1),买入股票的次数 j(j=1,2,...,k)和是否持有股票(1 表持有,0 表不持有). 特别注意买入股票的次数为 j 时,其实隐含了另一个信息,就是卖出股票的次数为 j-1 或 j 次。

状态转移方程
dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j][1]+prices[i]) # 右边:今天卖了昨天持有的股票,所以两天买入股票的次数都是j
dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j-1][0]-prices[i]) # 右边:昨天没有持股而今天买入一只,故昨天买入的次数是j-1
注意上面的转移方程只是穷举了第三个维度,要求出整个 DP Table 的状态,需要对 i 和 j 进行遍历。

边界状态
观察状态转移方程知,边界状态需要考虑两个方面:i=0 和 j=0
j=0 :
for i in range(n):
dp[i][0][0] = 0 # 没有买入过股票,且手头没有持股,则获取的利润为0
dp[i][0][1] = -float('inf') # 没有买入过股票,不可能持股,用利润负无穷表示这种不可能
i=0:
for j in range(1, k+1): # 前面j=0已经赋值了,这里j从1开始
dp[0][k][0] = 0
dp[0][k][1] = -prices[0]
特别注意,上述两轮边界定义有交集——dp[0][0][0] 和 dp[0][0][1] ,后者会得到不同的结果,应以 j=0 时赋值结果为准。

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        if n<=1: return 0
        dp = [[[None, None] for _ in range(3)] for _ in range(n)]  # (n, k+1, 2)
        
        # 边界状态需要考虑:1.j=0时对i穷举; 2.i=0时对有效的j穷举(j=1,2)
        for i in range(n):
            dp[i][0][0] = 0
            dp[i][0][1] = -float('inf')
        for j in range(1, 3):
            dp[0][j][0] = 0
            dp[0][j][1] = -prices[0]
        
        # 状态转移
        for i in range(1, n):
            for j in range(1, 3):
                dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j][1]+prices[i])
                dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j-1][0]-prices[i])
        
        return dp[-1][-1][0]

LeetCode 188

这道题理论上和 LeetCode 123(交易次数最多为2) 的解法一样,但是直接提交容易出现超内存的错误,是 DP Table 太大导致的。

有效的交易由买入和卖出构成,至少需要两天;反之,当天买入当天卖出则视为一次无效交易。如果题目给定的最大交易次数 k<=n/2,这个 k 是可以有效约束交易次数的;如果给定的 k>n/2 ,那这个 k 实际上起不到约束作用了,可以认为 k=+inf,本题退化为 LeetCode 122(不限交易次数) 。

题目整体思路是判断 k 和 n/2 的大小关系,两个分支分别用 LeetCode 123 和 LeetCode 122 的代码解决,可有效防止内存超出。

class Solution:
    def maxProfit(self, k: int, prices: List[int]) -> int:
        n = len(prices)
        if n <= 1: return 0

        if k >= n//2:   # 退化为不限制交易次数
            profit = 0
            for i in range(1, n):
                if prices[i] > prices[i - 1]:
                    profit += prices[i] - prices[i - 1]
            return profit

        else:           # 限制交易次数为k
            dp = [[[None, None] for _ in range(k+1)] for _ in range(n)]  # (n, k+1, 2)
            for i in range(n):
                dp[i][0][0] = 0
                dp[i][0][1] = -float('inf')
            for j in range(1, k+1):
                dp[0][j][0] = 0
                dp[0][j][1] = -prices[0]
            for i in range(1, n):
                for j in range(1, k+1):
                    dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j][1] + prices[i])
                    dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j-1][0] - prices[i])
            return dp[-1][-1][0]

博弈问题

石头游戏:你和你的朋友面前有一排石头堆,用一个数组 piles 表示,piles[i] 表示第 i 堆石子有多少个。你们轮流拿石头,一次拿一堆,但是只能拿走最左边或者最右边的石头堆。所有石头被拿完后,谁拥有的石头多,谁获胜。

石头的堆数可以是任意正整数,石头的总数也可以是任意正整数,这样就能打破先手必胜的局面了。比如有三堆石头 piles = [1,100,3],先手不管拿 1 还是 3,能够决定胜负的 100 都会被后手拿走,后手会获胜。

假设两人都很聪明,请你设计一个算法,返回先手和后手的最后得分(石头总数)之差。比如上面那个例子,先手能获得 4 分,后手会获得 100 分,你的算法应该返回 -96。

打家劫舍问题

LeetCode 198
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。

示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。

class Solution:
    def rob(self, nums: List[int]) -> int:
        if len(nums)==0: return 0
        if len(nums)==1: return nums[0]
        if len(nums)==2: return max(nums[0], nums[1])
        dp0 = nums[0]
        dp1 = nums[1]
        ans = max(dp0, dp1)
        for i in range(2, len(nums)):
            cur = max(dp0 + nums[i], dp1)
            dp0, dp1 = max(dp0,dp1), max(dp1, cur) 
            ans = max(cur, ans)
            #print(dp0, dp1, ans)
        return ans  

零钱兑换

LeetCode 322
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。

示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

示例 2:
输入:coins = [2], amount = 3
输出:-1

示例 3:
输入:coins = [1], amount = 0
输出:0

示例 4:
输入:coins = [1], amount = 1
输出:1

示例 5:
输入:coins = [1], amount = 2
输出:2

提示:
1 <= coins.length <= 12
1 <= coins[i] <= 231 - 1
0 <= amount <= 104

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        """
        动态规划解法(对重叠子问题进行优化):
        如何定义dp,如何确定子问题和状态转移方程。
        问题分析:以coins = [1,2,5],amount=100为例,
            3个子问题是:
                amount=99,coins=[1,2,5]
                子问题是:
                    amount=98,coins=[1,2,5]
                    amount=97, coins=[1,2,5]
                    amount=94, coins=[1,2,5]
                amount=98, coins=[1,2,5]
                子问题是:
                    amount=97, coins=[1,2,5]
                    amount=96, coins=[1,2,5]
                    amount=91, coins=[1,2,5]
                amount=95, coins=[1,2,5]
                子问题是:
                    amount=94, coins=[1,2,5]
                    amount=93, coins=[1,2,5]
                    amount=90, coins=[1,2,5]
            还可以继续向下拆分子问题。
            可以看到amount=97,amount=96,amount=94在不同的子问题里会进行重复计算
        故定义dp[i]记录金额为i时的最少银币个数。解除了重叠子问题里的重复计算。
        状态转移方程:
        dp[i] = min(dp[i-1], dp[i-2], dp[i-5]) + 1
        """
        if amount == 0: return 0   ###边界值
        dp = [float('inf') for _ in range(amount+1)]
        coin_set = set(coins)
        for i in range(1,amount+1):
            if i in coin_set:
                dp[i] = 1
                continue
            cur = []
            for coin in coins:
                if i > coin:
                    cur.append(dp[i-coin])
            if cur:
                dp[i] = min(cur)+1
            #print(i,dp[i],cur)
        if dp[amount] > amount:
            return -1
        else:
            return dp[amount]
原文地址:https://www.cnblogs.com/sandy-t/p/13736437.html