DP学习笔记
经典案例
动态规划经常用来求最优解。如何判断一个题目是否能用DP来解决,可以通过画递归树来确定是否存在重复子问题,如果存在,则可以通过存储子问题的解去优化算法,还有就是知道子问题解后,能否得到当前的解。DP最关键的是整理出重叠子问题,从而得到动态转移方程或动态转移表。
如何得到状态转移方程?可以在假设知道子问题解的情况下,看看如何得到当前的解。这是问题的关键。而前提是要定义清楚dp[i]=k的含义。
以下是一些典型案例。
0-1背包问题
题目:
背包总重量W,n个物品,每个物品重量不等,不能分割,期望选择几件物品,装载到背包中,不超过总重量的前提下,让包里面物品总重量最大
分析:
dp[i][w]=true/false 表示第i个物品选或者不选时,是否能达到w重量。算法复杂度为O(n*w)
动态转移方程
选第i个物品时:dp[i][w+item[i]]=dp[i-1][w]
不选第i个物品时:dp[i][w] = dp[i-1][w]
代码
//n表示一共有n个物品,w表示最多承受的重量 func knapsack(items []int, n, w int) int { //初始化状态转移表 states := make([][]bool, n) for i := 0; i < n; i++ { states[i] = make([]bool, w+1) } //初始化第0行的状态 states[0][0] = false if items[0] <= w { states[0][items[0]] = true } //开始状态转移,从第一行开始 for i := 1; i < n; i++ { //不选第i个 for j := 0; j <= w; j++ { states[i][j] = states[i-1][j] } //选第i个 for j := 0; j <= w-items[i]; j++ { states[i][j+items[i]] = states[i-1][j] } } for i := w; i >= 0; i-- { if states[n-1][i] { return i } } fmt.Println(states) return 0 }
股票买卖时机(买卖一次求最大profit)
题目:
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。
分析:
首先将题目转化为每次交易的profit序列,有亏有赢,也就是对item[i]和item[i-1]做差,得到profit[i]序列,现在只需求连续求和运算,求出最大的和即可。
动态转移方程:
dp[i]=max(dp[i-1]+profit[i], profit[i])
代码:
if len(prices) <= 1 { return 0 } diff := make([]int, len(prices)-1) for i:=1; i<len(prices); i++ { diff[i-1] = prices[i] - prices[i-1] } dp := make([]int, len(diff)) dp[0] = diff[0] profit := diff[0] for i:=1; i<len(diff); i++ { dp[i] = Max(dp[i-1]+diff[i], diff[i]) if profit < dp[i] { profit = dp[i] } } if profit < 0 { return 0 } return profit
莱特斯坦距离(编辑距离)
题目:
给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符
分析:
编辑距离:将一个字符串转化成另一个字符串,需要的最少编辑次数(增加一个字符,删除一个字符,替换一个字符)这类二维的题目(涉及到两个字符串的题目)可以用表格来帮助推到状态转移方程。
a,b表示两个字符串,dp[i][j]=k表示a/b的莱特斯坦距离。如果a[i]=b[j],则dp[i][j]=dp[i-1][j-1];如果a[i]!=b[j],那么比较复杂,如果比如两个字符串为"a","c",那么改任意一个即可,所以可以表示为dp[i][j]=min(dp[i-1][j],d[i][j-1])+1,如果是"ac"和"ag",那么
if s1[i] == s2[j]:
啥都别做(skip)
i, j 同时向前移动
else:
三选一:
插入(insert) dp[i][j]=dp[i][j-1]+1
删除(delete)dp[i][j]=dp[i-1][j]+1
替换(replace)dp[i][j]=dp[i-1][j-1]+1
注意,要考虑空串情况,比如a为"",b="abcf"这种情况。dp[i][j]=max{i,j}
状态转移方程
如果a[i]=b[j]:dp[i][j] = dp(i - 1, j - 1)
如果a[i]!=b[j]:dp[i][j] = min(dp(i, j - 1) + 1, dp(i - 1, j) + 1, dp(i - 1, j - 1) + 1)
代码:
func minDistance(a string, b string) int { lenA, lenB := len(a), len(b) if lenA == 0 || lenB == 0 { return int(math.Max(float64(lenA), float64(lenB))) } lev := make([][]int, lenA+1) for i := 0; i <= lenA; i++ { lev[i] = make([]int, lenB+1) } //初始化第一列 for i := 0; i <= lenA; i++ { lev[i][0] = i } //初始化第一行 for j := 0; j <= lenB; j++ { lev[0][j] = j } //计算 for i := 1; i <= lenA; i++ { for j := 1; j <= lenB; j++ { if a[i-1] == b[j-1] { lev[i][j] = lev[i-1][j-1] } else { lev[i][j] = int(math.Min(math.Min(float64(lev[i-1][j]), float64(lev[i][j-1])), float64(lev[i-1][j-1]))) + 1 } } } return lev[lenA][lenB] }
最长公共子串
题目:
给两个整数数组 A
和 B
,返回两个数组中公共的、长度最长的子数组的长度。
分析:
公共子串一定是连续的,所以dp[i][j]表示以a[i]和b[j]结尾的最长子串长度(如果不是以i,j结尾,那么dp[i+1][j+1]就不是子问题了)
动态转移方程:
情况一、a[i]=b[j]: dp[i][j] = dp[i-1][j-1]+1
情况二、a[i]不等于b[j]:dp [i][j]=0 (不等时得重新计算)
func longestCommonSubstring(a, b string) int { dp := make([][]int, len(a)) for i, _ := range dp { dp[i] = make([]int, len(b)) } //初始化第一列 for i := 0; i < len(a); i++ { if a[i] == b[0] { dp[i][0] = 1 } else { dp[i][0] = 0 } } //初始化第一行 for j := 0; j < len(b); j++ { if a[0] == b[j] { dp[0][j] = 1 } else { dp[0][j] = 0 } } longest := 0 for i := 1; i < len(a); i++ { for j := 1; j < len(b); j++ { if a[i] == b[j] { dp[i][j] = dp[i-1][j-1] + 1 longest = int(math.Max(float64(dp[i][j]), float64(longest))) } else { dp[i][j] = 0 } } } return longest }
最长公共子序列
题目:
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。
若这两个字符串没有公共子序列,则返回 0。
分析:
dp[i][j]=k表示长度为i的序列a和长度为j的序列b的最长公共子串的长度(子串不一定要以i和j结尾,这个很重要,和最长公共子串不一样);当知道之前状态时,能否得到当前的状态,如果能,则可以用动态规划。
动态转移方程:
情况一、a[i]=b[j]: dp[i][j] = dp[i-1][j-1]+1
情况二、a[i]不等于b[j]:dp [i][j]=max{dp[i-1][j], dp[i][j-1]}
//最长公共子序列,可以不连续 func longestCommonSubsequence(a, b string) int { lenA, lenB := len(a), len(b) dp := make([][]int, lenA) for i, _ := range dp { dp[i] = make([]int, lenB) } //初始化dp第0列和第0行 for j := 0; j < lenB; j++ { if a[0] == b[j] || (j >= 1 && dp[0][j-1] == 1) { dp[0][j] = 1 } else { dp[0][j] = 0 } } for i := 0; i < lenA; i++ { if a[i] == b[0] || (i >= 1 && dp[i-1][0] == 1) { dp[i][0] = 1 } else { dp[i][0] = 0 } } for i := 1; i < lenA; i++ { for j := 1; j < lenB; j++ { if a[i] == b[j] { dp[i][j] = dp[i-1][j-1] + 1 } else { dp[i][j] = int(math.Max(float64(dp[i-1][j]), float64(dp[i][j-1]))) } } } return dp[lenA-1][lenB-1] }
最长单调递增子串
题目
给定一个字符串(或数组),求最长的连续单调自增子串。
分析
定位dp[i]=k表示以item[i]结尾的最长单调自增子串长度为k。那么如果item[i+1]比前一个大,dp[i+1]=dp[i]+1,如果小于等于前一个,那么dp[i+1]=1
动态转移方程
如果item[i]>item[i-1],则dp[i]=dp[i-1]+1
否则dp[i]=1
代码略。。。。
最长单调递增子序列
题目
给定一个无序的整数数组,找到其中最长上升子序列的长度。
分析
dp[i]=k表示以item[i]结尾的最长递增子序列的长度为k。当知道前i个dp的值后,怎么求第i+1个结尾的最长子序列长度呢:依此从后往前遍历,找到前i个中比item[i+1]小的item[j],求出dp[i]=dp[j]+1,取最大值为dp[i]。算法复杂度为O(n*n)
状态转移方程
dp[i]=max{1, dp[j]+1} (item[i]>item[j], 0<=j<i)
代码
func lengthOfLIS(nums []int) int { if nums == nil || len(nums) == 0 { return 0 } //初始化dp为1 dp := make([]int, len(nums)) longest := 1 for i := 0; i < len(nums); i++ { dp[i] = 1 for j := i - 1; j >= 0; j-- { if nums[i] > nums[j] { dp[i] = int(math.Max(float64(dp[i]), float64(dp[j]+1))) if longest < dp[i] { longest = dp[i] } } } } return longest }
乘积最大子序列
题目:
给定一个整数数组 nums
,找出一个序列中乘积最大的连续子序列(该序列至少包含一个数)。
分析:
数字每乘一次,有五种情况,从正变成负(极大到极小);从负变成正(极小变为极大);变为0(重新开始算);正数还是正数(变大);负数还是负数(变小);所以需要记录下最大值(正数),最小值(负数),如果是0,则重新开始算;所以需要两个dp数组用来记录以a[i]结束的最大值和最小值,防止乘后符号变化。
动态转移方程:
dpMax[i] = max{dp[i-1]*a[i], a[i], dpMin[i-1]*a[i]}
dpMin[i] = min{dp[i-1]*a[i], a[i], dpMin[i-1]*a[i]}
if len(nums) ==0 { return 0 } dpMax := make([]int, len(nums)) dpMin := make([]int, len(nums)) dpMax[0] = nums[0] dpMin[0] = nums[0] max := nums[0] for i:=1; i<len(nums); i++ { dpMax[i] = getMax(dpMax[i-1]*nums[i], dpMin[i-1]*nums[i], nums[i]) dpMin[i] = getMin(dpMax[i-1]*nums[i], dpMin[i-1]*nums[i], nums[i]) if max < dpMax[i] { max = dpMax[i] } } return max
Coin Change (零钱兑换)
题目:
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
分析:
dp[n]=k表示总金额为n时,最少需要k枚硬币。
动态转移方程:
n=0时,dp[n]=0
n<0时,dp[n]=-1
n>=1时,dp[n]=dp[n-coin]+1, coin为所有的coin[]中的一个,每一个都要试一试。因此算法复杂度为amount*len(coins)
代码
func coinChange(coins []int, amount int) int { if amount == 0 { return 0 } if amount <= 0 { return -1 } dp := make([]int, amount+1) dp[0] = 0 for i := 1; i <= amount; i++ { dp[i] = math.MaxInt32 } for i := 1; i <= amount; i++ { for _, coin := range coins { if i-coin < 0 || dp[i-coin] == -1 { continue } if dp[i] > dp[i-coin]+1 { dp[i] = dp[i-coin] + 1 } } if dp[i] == math.MaxInt32 { dp[i] = -1 } } return dp[amount] }