动态规划

早过忘川 提交于 2020-01-20 01:39:57

动态规划 Dynamic Programming

一种设计的技巧,是解决一类问题的方法
dp遵循固定的思考流程:暴力递归 —— 递归+记忆化 —— 非递归的动态规划(状态定义+转移方程)

 

斐波那契数列

暴力递归,看上去很简洁

def fib(n):
    return n if n <= 1 else fib(n-1) + fib(n-2)

 

画出递归树分析一下,可以很容易发现有很多重复计算。重叠子问题

递归算法的时间复杂度怎么计算?子问题个数乘以解决一个子问题需要的时间。显然,斐波那契数列的递归解法时间复杂度为O(2n * 1),暴力递归解法基本都会超时。

 

如何解决?递归 + 记忆化

仍然使用递归,不同点在于,如果重叠子问题已经计算过,就不用再算了,相当于对冗余的递归树进行了剪枝。

由于不存在重叠子问题,时间复杂度为O(n * 1),降到线性。

 1 class Solution:
 2     def Fibonacci(self, n):
 3         # write code here
 4         if n <= 1:
 5             return n
 6         memo = [-1] * (n+1)
 7         memo[0], memo[1]= 0, 1
 8         
 9         def helper(n, memo):
10             if memo[n] >= 0:
11                 return memo[n]
12             memo[n] = helper(n-1, memo) + helper(n-2, memo)
13             return memo[n]
14         
15         return helper(n, memo)
View Code

 

实际上这已经和动态规划一样了,只不过这是自顶向下的。而动态规划是自底向上的。从最小的子问题一步一步向上递推,O(n)

 1 class Solution:
 2     def Fibonacci(self, n):
 3         # write code here
 4         if n <= 1:
 5             return n
 6         dp = [0] * (n+1)
 7         dp[0], dp[1] = 0, 1
 8         for i in range(2, n+1):
 9             dp[i] = dp[i-1] + dp[i-2]
10         return dp[n]
View Code

 

进一步优化空间复杂度,由于只要最后一个状态,而且状态转移只取决于相邻的状态,不需要开一维数组,直接两个变量滚动更新就行了。

1 class Solution:
2     def Fibonacci(self, n):
3         # write code here
4         if n <= 1:
5             return n
6         dp_0, dp_1 = 0, 1
7         for i in range(2, n+1):
8             dp_0, dp_1 = dp_1, dp_0 + dp_1
9         return dp_1
View Code

 

从一道题展开

最长公共子序列 LCS

给定长度为 m 和 n 的两个数组 x 和 y,找出最长的公共子序列(可能有多个)

x : ABCBDAB

y : BDCABA

存在3个最长公共子序列,长度为4,分别为 BDAB、BCAB、BCBA

 

1. 穷举,穷举x中的所有子序列,再检查y里面是不是也有一样的子序列

  假设给定了一个子序列,检查它是否为 y 的子序列的复杂度?O(n),按顺序把 y 对着给定的子序列往后捋一遍即可

  x 有多少子序列?2m ,每个元素都可以选或者不选。

  所以穷举的时间复杂度,O(n2m),指数级

 

2. 先确定 LCS 的长度,再看看具体有哪些公共子序列达到了这个长度

  只要考察前缀即可。定义 c[i, j] 表示 x[1...i] 和 y[1...j] 的 LCS 长度,c[m, n] 就为x 和 y 的 LCS 长度。

    base cases: c[*, 0] = 0 且 c[0, *] = 0。此外,当 x[i] == y[j] 时,c[i, j] = c[i-1, j-1] + 1;否则 c[i, j] = max(c[i, j-1], c[i-1, j])。这里比较好理解,稍微想一下就清楚了,x[i] 和 y[j] 相等的时候,这个值可以直接算到 LCS 中,所以加1。否则的话,就看看x[i] 和 y[j] 各自算进去哪种情况的 LCS 大。

  上面结论的一点证明:x[i] == y[j] 时,令 z[1...k] = LCS(x[1...i], y[1...j]),显然 k = c[i, j]。z[k] 就是 x[i] 同时也为 y[j] ,显然一定有 z[1...k-1] = LCS(x[1...i-1], y[1...j-1]) ,c[i-1, j-1] = k-1,即 c[i, j] = c[i-1, j-1] + 1 ;x[i] != y[j] 时的证明类似。

 

由此引出动态规划的第一个特征,最优子结构,指的是问题的一个最优解包含了子问题的最优解。

If z = LCS(x, y),  then any prefix of z is an LCS(a prefix of x ,  a prefix of y).

 

递归实现一下 LCS 计算长度

def LCS(x, y, i, j):
    """ignore base cases"""
    if x[i] == y[j]:
        c[i, j] = LCS(x, y, i-1, j-1) + 1
    else:
        c[i, j] = max(LCS(x, y, i-1, j), LCS(x, y, i, j-1))
    
    return c[i, j]

  

考虑一下这个递归树,最坏情况下每次都要走取 max 的分支,递归树深度为 m+n,时间复杂度为 O(2m+n)。可以发现有很多重复计算。由此引出动态规划的第二个特征,重叠子问题

 

LCS 问题的子问题有 m*n 个,每次算好了就存下来,备忘法

def LSC(x, y, i, j):
    if c[i, j] != None:
        return c[i, j]

    if x[i] == y[j]:
        c[i, j] = LCS(x, y, i-1, j-1) + 1
    else:
        c[i, j] = max(LCS(x, y, i-1, y), LCS(x, y, i, j-1))
    
    return c[i, j]

  

这个计算所需要的时间?O(m*n),因为摊销之后每个子问题都只需要执行常数次计算得到结果

空间?O(m*n),建表

 

自底向上地计算表格  ——动态规划

 1 def lcs_length(x, y):
 2     if not x or not y:
 3         return 0
 4     m, n = len(x), len(y)
 5     dp = [[0]*(n+1) for _ in range(m+1)]
 6     
 7     for i in range(m+1):
 8         for j in range(n+1):
 9             if i == 0 or j == 0:
10                 dp[i][j] = 0
11             elif x[i-1] == y[j-1]:
12                 dp[i][j] = dp[i-1][j-1] + 1
13             else:
14                 dp[i][j] = max(dp[i-1][j], dp[i][j-1])
15     
16     return dp[-1][-1]
View Code

 

优化空间复杂度,比较简单的做法就是用滚动数组优化到 O(2*min(m, n)) ,因为每次都需要查看 dp 表的上一行和这一行的左边。

 1 def lcs_length(x, y):
 2     if not x or not y:
 3         return 0
 4     m, n = len(x), len(y)
 5     if n > m:
 6         m, n, x, y = n, m, y, x
 7         
 8     dp = [[0]*(n+1) for _ in range(2)]
 9     
10     pre, now = 0, 1
11     for i in range(1, m+1):
12         pre, now = now, pre
13         for j in range(1, n+1):
14             if x[i-1] == y[j-1]:
15                 dp[now][j] = dp[pre][j-1] + 1
16             else:
17                 dp[now][j] = max(dp[pre][j], dp[now][j-1])
18                 
19     return dp[now][-1]
View Code

 

在得到 LCS 长度的同时如何得到子序列?根据 dp 表回溯,走到每个位置的时候记录一下从哪里来的。整道题的答案就搞定了

 1 def lcs_length(x, y):
 2     if not x or not y:
 3         return 0
 4     m, n = len(x), len(y)
 5     dp = [[0]*(n+1) for _ in range(m+1)]
 6     
 7     # 1:左上、2:上、3:左、4:上或左
 8     states = [[0]*(n+1) for _ in range(m+1)]
 9     
10     for i in range(1, m+1):
11         for j in range(1, n+1):
12             if x[i-1] == y[j-1]:
13                 dp[i][j] = dp[i-1][j-1] + 1
14                 states[i][j] = 1
15                 
16             elif dp[i-1][j] > dp[i][j-1]:
17                 dp[i][j] = dp[i-1][j]
18                 states[i][j] = 2
19                 
20             elif dp[i-1][j] < dp[i][j-1]:
21                 dp[i][j] = dp[i][j-1]
22                 states[i][j] = 3
23             else:
24                 dp[i][j] = dp[i][j-1]
25                 states[i][j] = 4
26                 
27     lcsLength = dp[-1][-1]            
28     printAllLCS(states, x, lcsLength, m, n, '')           
29     return lcsLength
30 
31 def  printAllLCS(states, x, lcsLength, i, j, lcs):
32     """states表;只需要一个字符串就够了;LCS长度;当前位置ij;已搜索轨迹lcs"""
33     if i == 0 or j == 0:
34         if len(lcs) == lcsLength:
35             print(lcs[::-1])  # 从后往前dfs搜索的,这里逆序输出
36         return
37     
38     direction = states[i][j]
39     if direction == 1:
40         printAllLCS(states, x, lcsLength, i-1, j-1, lcs+x[i-1])
41     elif direction == 2:
42         # 同一行或者同一列转移过来的字符没有变化
43         printAllLCS(states, x, lcsLength, i-1, j, lcs)
44     elif direction == 3:
45         printAllLCS(states, x, lcsLength, i, j-1, lcs)
46     elif direction == 4:
47         # 两个来源都有可能
48         printAllLCS(states, x, lcsLength, i-1, j, lcs)
49         printAllLCS(states, x, lcsLength, i, j-1, lcs)
View Code

 

 

带权项目时间计划

典型的选还是不选的问题。OPT(i) 表示一共有前 i 个任务的话,最多能挣多少钱,那么从后往前考虑,就是第i个任务选还是不选。如果不选,OPT(i) = OPT(i-1);如果选了,就看选了第i个,再往前有几个能做,比如如果选了8,那么前面只能从5往前选,用prev(i)来表示这个索引,即prev(8) = 5。所以递推公式已经列出来了,而prev(i)是可以先确定的。

 

写出递归树可以发现这是个重叠子问题,用动态规划求解即可。

 

 

和最大的不连续子数组

给定一个数组,选出和最大的子数组,长度不限,但不能选相邻元素。例如 [4, 1, 1, 9, 1],满足条件的和最大子数组为 [4, 9]。定义 OPT(i) 为到下标为 i 的数为止的最大不连续子数组之和。如果选了下标为 i 的数,那么前面最多能选到下标 i-2;如果不选则前面能选到 i-1

 1 def maxSubArray(arr):
 2     if not arr:
 3         return 0
 4     
 5     n = len(arr)
 6     if n == 1:
 7         return arr[0]
 8     
 9     dp = [0]*n
10     dp[0], dp[1] = arr[0], max(arr[0], arr[1])
11     
12     for i in range(2, n):
13         dp[i] = max(dp[i-2] + arr[i], dp[i-1])
14         
15     return dp[n-1]
View Code

 

 

和为给定值的子数组

给定一个正整数数组和一个正整数目标值,判断能否找到一个子数组,和恰好为给定的目标值。subset(i, s),i表示当前看第i个数字、s为目标值。对于每个当前数字,有选或不选两种可能,只要有一种满足条件即可。

出口情况:s为0,返回 true;i为0,只有当 s == arr[0] 才返回true;如果 arr[i] > s,选上一定超,只考虑不选arr[i]的情况

if s == 0:
    return True
if i == 0:
    return arr[i] == s
if arr[i] > s:
    return subset(i-1, s)

  

递归写一下

 1 def solution(array, s):
 2     if not array:
 3         return False
 4     n = len(array)
 5     
 6     def helper(arr, i, s):
 7         if s == 0:
 8             return True
 9         if i == 0:
10             return arr[i] == s
11         if arr[i] > s:
12             return helper(arr, i-1, s)
13         return helper(arr, i-1, s-arr[i]) or helper(arr, i-1, s)
14     
15     return helper(array, n-1, s)
View Code

 

动态规划写一下,显然是一个二维 dp,递归出口就是 dp 的初始化的条件

 1 def solution(array, s):
 2     if not array:
 3         return False
 4     n = len(array)
 5     dp = [[False]*(s+1) for _ in range(n)]
 6     
 7     for i in range(n):
 8         dp[i][0] = True
 9         
10     dp[0][array[0]] = True
11     
12     for i in range(1, n):
13         for t in range(1, s+1):
14             if array[i] > t:
15                 dp[i][t] = dp[i-1][t]
16             else:
17                 dp[i][t] = dp[i-1][t-array[i]] or dp[i-1][t]
18     return dp[-1][-1]
View Code

 

 

零钱兑换

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

暴力法,先考虑一下递归关系。 总金额为0时,f(amount) = 0;总金额不为0时,f(amount) = 1 + min{ f(amount - ci) | i 属于 [1, k] }。

解释一下,要求总金额为amount的最少硬币个数,先选一个合法可选面值的硬币,总金额变为amount - ci,总的最少硬币数等于子问题的最优解+1。

这里用到了最优子结构性质,即原问题的解由子问题的最优解构成。要符合最优子结构,子问题之间必须独立。

 1 import sys
 2 class Solution:
 3     def coinChange(self, coins: List[int], amount: int) -> int:
 4         if amount == 0:
 5             return -1
 6         ans = sys.maxsize
 7         for coin in coins:
 8             if amount < coin:  # 金额不可达
 9                 continue
10             subProblem = self.coinChange(coins, amount-coin)
11             
12             if subProblem == -1:  # 子问题无解
13                 continue
14             ans = min(ans, subProblem + 1)
15         
16         return -1 if ans == sys.maxsize else ans
View Code

 

递归+记忆化

 1 import sys
 2 class Solution:
 3     def coinChange(self, coins: List[int], amount: int) -> int:
 4         if not coins:
 5             return -1
 6         memo = [-2] * (amount+1)   # memo[amount]表示凑到金额为amount的最少硬币数
 7         
 8         def helper(coins, amount, memo):
 9             if amount == 0:
10                 return 0
11             if memo[amount] != -2:
12                 return memo[amount]
13             
14             ans = sys.maxsize
15             for coin in coins:
16                 if amount < coin:  # 金额不可达
17                     continue
18                 subProblem = helper(coins, amount-coin, memo)
19 
20                 if subProblem == -1:  # 子问题无解
21                     continue
22                 ans = min(ans, subProblem + 1)
23             
24             memo[amount] = -1 if ans == sys.maxsize else ans  # 记录本轮答案
25             return memo[amount]  
26         
27         return helper(coins, amount, memo)
View Code

 

动态规划,按上面描述的状态方程。

 1 import sys
 2 class Solution:
 3     def coinChange(self, coins: List[int], amount: int) -> int:
 4         if not coins:
 5             return 0
 6         dp = [sys.maxsize] * (amount+1)
 7         dp[0] = 0
 8         
 9         for i in range(1, amount+1):
10             for j in range(len(coins)):
11                 if i < coins[j]:
12                     continue
13                 dp[i] = min(dp[i], dp[i - coins[j]] + 1)
14             
15         return -1 if dp[amount] == sys.maxsize else dp[amount]
View Code

 

 

出发到终点所有可能的路径问题

只能向右或向下,涂实的点不能走。考虑每个出发点可能的路径数,等于右边一格作为出发点的路径数+下边一格作为出发点的路径数。

暴力递归,自顶向下

 

自底向上递推,如果要到达a[i, j]点,只能从它的上面或者左边经过:

opt[i, j] = opt[i-1, j] + opt[i, j-1]

# --------------------------------------

if isValid(a[i, j]):
    opt[i, j] = opt[i-1, j] + opt[i, j-1]
else:
    opt[i, j]  = 0   # 石头

 

 

正则表达式

给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。

'.' 匹配任意单个字符
'*' 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。

说明:

s 可能为空,且只包含从 a-z 的小写字母。
p 可能为空,且只包含从 a-z 的小写字母,以及字符 . 和 *。

先看不管通配符,两个普通字符串进行比较应该怎么写。然后再改成比较通用的框架,再写成递归

1 def isMatch(text, pattern):
2     if len(text) != len(pattern):
3         return False
4     for j in range(len(pattern)):
5         if pattern[j] != text[j]:
6             return False
7     return True
View Code
 1 def isMatch(text, pattern):
 2     m, n = len(text), len(pattern)
 3     i, j = 0, 0   # 双指针 
 4     while j < n:
 5         if i >= m:  # 如果text的指针越界了但pattern的指针没有,说明没有待匹配的字符了但模式串还剩下,不匹配
 6             return False
 7         if pattern[j] != text[i]: 
 8             return False
 9         j += 1
10         i += 1
11     return j == n  # 最后看模式串字符是不是都匹配完了
View Code
1 def isMatch(text, pattern):
2     if len(pattern) == 0:
3         return len(text) == 0
4     first_match = len(text) != 0 and text[0] == pattern[0]
5     return first_match and isMatch(text[1:], pattern[1:])
View Code

 

然后处理通配符,'.' 可以匹配任意一个字符,所以判断能不能匹配的时候有两种情况,直接匹配或者用'.'匹配

1 def isMatch(text, pattern):
2     if not pattern:
3         return not text
4     first_match = bool(text) and pattern[0] in {text[0], '.'}
5     return first_match and isMatch(text[1:], pattern[1:])
View Code

 

再处理'*',星号可以让之前的一个字符出现任意次数,包括0次。关键就是出现几次呢,交给递归好了,当前只可能出现0次或者1次。如果匹配前一个字符0次,那就直接从模式串的p[2:] 再匹配文本串;如果当前匹配一次,那文本串要向后移动一位,后面还需要匹配几次交给递归。

1 def isMatch(text, pattern):
2     if not pattern:
3         return not text
4     first_match = bool(text) and pattern[0] in {text[0], '.'}
5     if len(pattern) >= 2 and pattern[1] == '*':  # '*' 不能放首位,发现'*'
6         return isMatch(text, pattern[2:]) or (first_match and isMatch(text[1:], pattern))
7     # else
8     return first_match and isMatch(text[1:], pattern[1:])
View Code

 

然后加上记忆化

 1 def isMatch(text, pattern):
 2     memo = dict()
 3     def dp(i, j):
 4         if (i, j) in memo:
 5             return memo[(i, j)]
 6         if j == len(pattern):
 7             return i == len(text)
 8         first_match = i < len(text) and pattern[j] in {text[i], '.'}
 9         if j <= len(pattern)-2 and pattern[j+1] == '*':
10             ans = dp(i, j+2) or (first_match and dp(i+1, j))
11         else:
12             ans = first_match and dp(i+1, j+1)
13         memo[(i, j)] = ans
14         return ans
15     
16     return dp(0, 0)
View Code

 

 

如何判断是不是重叠子问题:

  1. 随便假设一个输入,画递归树

  2. 先抽象出递归算法的框架,然后判断原问题是如何到达子问题的,看看不同的路径是不是都到达了同一个问题,如果是的话那就是重叠子问题。例如这题

def dp(i ,j):
    dp(i, j+2) # 1
    dp(i+1, j) # 2
    dp(i+1, j+1) # 3

  

dp(i, j) 如何到达 dp(i+2, j+2)。 dp(i, j) -> #3 -> #3;或者dp(i, j) -> #1 -> #2 -> #2;或者dp(i, j) -> #2 -> #2 -> #1,所以一定存在重叠子问题,一定需要动态规划技巧来优化。

 1 def isMatch(text, pattern):
 2         dp = [[False] * (len(pattern)+1) for _ in range(len(text)+1)]
 3          
 4         dp[-1][-1] = True  # 空串匹配空串
 5         
 6         for i in range(len(text), -1, -1):
 7             for j in range(len(pattern)-1, -1, -1):
 8                 first_match = i < len(text) and pattern[j] in {text[i], '.'}
 9                 if j <= len(pattern)-2 and pattern[j+1] == '*':
10                     dp[i][j] = dp[i][j+2] or (first_match and dp[i+1][j])
11                 else:
12                     dp[i][j] = first_match and dp[i+1][j+1]
13                      
14         return dp[0][0]
View Code

 

 

设计动态规划的通用技巧:数学归纳

最长递增子序列

给定一个无序的整数数组,找到其中最长上升子序列的长度。

定义 dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度。根据这个定义,子序列的最大长度应该是dp数组中的最大值。假设已经知道了 dp[0...i-1] 的结果,如何通过这些已知结果推出 dp[i] 呢,这个就是状态转移方程了。显然要知道 nums[i] 能不能加入到上升子序列中,就要找到前面那些结尾比 nums[i] 小的子序列,然后再把 nums[i] 接上,因为要求最大子序列,所以就接上之前的最大子序列即可。剩下的就是base case,这题 dp 数组初始化为1,因为子序列最少也要包含自己。

 1 class Solution:
 2     def lengthOfLIS(self, nums: List[int]) -> int:
 3         if not nums:
 4             return 0
 5         n = len(nums)
 6         dp = [1] * n
 7         for i in range(n):
 8             for j in range(i-1, -1, -1):
 9                 if nums[j] < nums[i]:
10                     dp[i] = max(dp[i], dp[j] + 1)
11         return max(dp)
View Code

 

但这道题还有一种 O(NlogN) 的解法,但是不看答案估计很难想得出。把上面方法中内层 j 循环替换成二分。始终维护一个数组 LIS 为要求的上升子序列,对每一个 nums[i],都插入到LIS中(二分法找到第一个比 nums[i] 大的数替换掉,因为这样尽可能多的让后面符合条件的数进来、缩一下上界。如果 nums[i] 比 LIS 所有都大就直接 append),最后 LIS 的长度即为所求。 代码在 Leetcode-动态规划 https://www.cnblogs.com/chaojunwang-ml/p/11365562.html

 

 

从最长上升子序列到信封嵌套

俄罗斯套娃信封问题

这道题是最长上升子序列的升维,要先对宽度进行升序排列,然后对宽度相同的按高度降序排序。最后对高度数组进行最长上升子序列的求解

 1 class Solution:
 2     def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
 3         if not envelopes:
 4             return 0
 5         n = len(envelopes)
 6         nums = sorted(envelopes, key=lambda x: [x[0], -x[1]])
 7         dp = [1] * n
 8         
 9         for i in range(n):
10             for j in range(i-1, -1, -1):
11                 if nums[i][1] > nums[j][1]:
12                     dp[i] = max(dp[i], dp[j] + 1)
13         return max(dp)
View Code

  

用刚才提到的二分法来优化

 1 class Solution:
 2     def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
 3         if not envelopes:
 4             return 0
 5         n = len(envelopes)
 6         nums = sorted(envelopes, key=lambda x: [x[0], -x[1]])
 7         LIS = []
 8         
 9         for i in range(n):
10             if not LIS or nums[i][1] > LIS[-1]:
11                 LIS.append(nums[i][1])
12             else:
13                 index = self.binarySearch(LIS, nums[i][1])
14                 LIS[index] = nums[i][1]
15         return len(LIS)
16     
17     def binarySearch(self, array, target):
18         """返回第一个比target大的元素索引"""
19         if not array:
20             return
21         low, high = 0, len(array) - 1
22          
23         while low <= high:
24             mid = low + (high-low)//2
25             if array[mid] < target:
26                 low = mid + 1
27             elif array[mid] > target:
28                 high = mid - 1
29             else:
30                 return mid
31         return low
View Code

 

 

 

博弈问题的思路是在二维dp的基础上使用元组分别存储两个人的博弈结果。

一堆石头用数组piles表示,piles[i]表示第i堆有多少个石头,两个人拿石头,一次拿一堆,但只能拿走最左边或者最右边。所有石头被拿完后,谁拥有但石头多谁获胜。

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

博弈问题的通用框架。

定义dp数组,dp[i][j] = (first, second),dp[i][j].fir 表示对于 piles[i,...,j]这部分,先手能获得的最高分数,dp[i][j].sec 表示后手能获得的最高分数

对于每个状态,可以做的选择有两个:选择最左边的还是最右边的。那么穷举状态:

for 0<=i <n:
    for  i<=j < n:
        for who in {first, second}:
            dp[i][j][who] = max(left, right)

  

但是先手的选择会对后手有影响。面对piles[i,...,j]先手选了左边,然后面对piles[i+1,...,j]但对方先选,自己变成后手。或者先手选了右边,然后面对piles[i,...,j-1]自己后手。

dp[i][j].fir = max(piles[i] + dp[i+1][j].sec, piles[j] + dp[i][j-1].sec)  

如果作为后手,就要等先手先选择,如果对方先手选了最左边,自己先手面对piles[i+1,...,j];

dp[i][j].sec = dp[i+1][j].fir

如果对方先手选了右边,自己先手面对piles[i,...,j-1]

dp[i][j].sec = dp[i][j-1].fir

 

那么,base case也容易确定,当i==j,也就是只有一堆的时候,先手得分为piles[i],后手不得分0。但是 base case 在 dp table 中是斜的,而且计算dp[i][j]的时候需要dp[i+1][j] 和dp[i][j-1],所以要斜着遍历数组。(怎么实现?按对角线斜线往下,一条一条遍历)

 1 class Pair:
 2     def __init__(self, fir, sec):
 3         self.fir = fir
 4         self.sec = sec
 5         
 6 def stoneGame(piles):
 7     if not piles:
 8         return 0
 9     n = len(piles)
10     dp = [[Pair(-1, -1) for _ in range(n)] for _ in range(n)]
11     
12     for i in range(n):
13         dp[i][i].fir = piles[i]
14         dp[i][i].fir = 0
15         
16     # 斜着遍历
17     for l in range(1, n):  # 目前遍历的是第几条斜线,第0条初始化了
18         for i in range(n-l):  # dp[i][j]需要dp[i+1][j] 和dp[i][j-1]
19             j = l + i  # j的坐标始终比i多l
20             left = piles[i] + dp[i+1][j].sec
21             right = piles[j] + dp[i][j-1].sec
22             
23             if left > right:
24                 dp[i][j].fir = left
25                 dp[i][j].sec = dp[i+1][j].fir
26             else:
27                 dp[i][j].fir = right
28                 dp[i][j].sec = dp[i][j-1].fir
29                 
30     return dp[0][n-1].fir - dp[0][n-1].sec
View Code

 

 

背包问题

01背包

N 件物品,容量为 C 的背包,第 i 件物品的重量为 Wi,价值为Vi。求装的最大价值

每件物品要么取要么不取。dp[i, j] 表示取到前 i 件物品,容量为 j 的最大价值,

dp[i, j] = max(dp[i-1, j],  dp[i-1, j - Wi] + Vi) i:1~n  j:0~W

 

如果倒着遍历,可以用滚动数组把 dp 数组优化到一维,dp[j] = max(dp[j], dp[j-Wi]+Vi)  j:W~0

 

完全背包

N 件物品,容量为 C 的背包,第 i 件物品的重量为 Wi,价值为Vi,每件物品有无数个。求装的最大价值

每件物品可以从不取,一直取到背包满了为止。dp[i, j] 表示取到前 i 件物品,容量为 j 的最大价值,

dp[i, j] = max( dp[i-1, j - k*Wi] + k*Vi  | 0 <= k<= j//Wi  )

 

考虑一下优化,对于 dp[i, j] ,选择 k 个;等价于 dp[i, j-Wi] 选择 k-1 个,这是两个重复计算的状态。

所以把 k=0 的状态提出来,dp[i, j] = max{dp[i-1, j],  dp[i-1, j-k*Wi] + k*Vi, 1<= k <= j//Wi}

dp[i-1, j-Wi-k*Wi] + k*Vi + Vi | 0 <= k <= (j-Wi)//Wi ;对所有的 k 取 max,就等价于 dp[i, j - Wi] + Vi

所以 dp[i, j] = max( dp[i-1, j], dp[i, j - Wi] + Vi)

就得到了 O(CN) 的算法。

 

滚动数组优化,但这里要注意的是正着遍历,因为 dp[i, j - Wi] 是当前层的值。dp[j] = max(dp[j], dp[j-Wi]+Vi)  j:0~W

 

硬币兑换

仅有1分、2分、3分的硬币,将钱 N 兑换成硬币有多少种方法。N < 32768

用完全背包的思路来思考,dp[i, j] = dp[i-1, j] + dp[i, j-a[i]];进一步优化成 dp[j] = dp[j] + dp[j-a[i]]

 

 

DP vs 回溯 vs 贪⼼

回溯(递归) — 重复计算
(没有最优子结构的话就是需要穷举所有的可能,而且不存在重复计算的问题)

贪⼼算法 — 永远局部最优
(但处处局部最优可能最后不是全部最优)

动态规划 — 记录局部最优⼦子结构 / 多种记录值(避免重复计算,只需依赖前一状态的最优值)

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!