通常求最值的问题会用到动态规划。
动态规划涉及到三个特性,重叠子问题,最优子结构和状态转移方程。解决动态规划问题本质就是列出状态转移方程,一旦列出状态转移方程,便可以用蛮力法穷举得到结果。由于动态规划问题存在重叠子问题的特征,因此蛮力法计算过程中会出现大量重复的计算,动态规划只是将重复的计算结果缓存了下来,下次遇到重复计算用查表来代替执行,从而加快执行效率。而列出动态转移方程的关键在于找到最优子结构。
解题思路
首先,我们要找到问题的所有状态列出状态方程。即f=dp(state1,state2,...,),状态通常是每做出一次选择会出现变化的变量,而函数值f通常是我们要求出来的最值。状态方程是解决问题的核心,如果该状态方程找不到最优子结构,需要重新设定状态方程。
其次,我们要找到所有选择,并找出每一种选择会让状态发生怎样的变化。
然后,我们要通过找到最优子结构列出状态转移方程。最优子结构可以通过数学归纳法找到。假设f=dp(state1,state2)。dp(j,k) 0<=j<=state1-1,0<=k<=state2-1 均已知,我们要能够根据所有可能的选择推到出dp(state1,state2)。
列出状态转移方程后就可以代码实现了,dp可以是函数(自顶向下)也可以是数组(自底向上),通常使用数组自底向上构建。代码模版如下:
1、根据状态个数和状态的取值初始化数组。
2、给数组赋值base case
3、遍历填充数组,根据状态转移方程写出填充逻辑。注意,dp[i]正向遍历还是逆向遍历,取决于状态转移方程中 dp[i]是有dp[i-1]决定还是dp[i+1]决定。
4、数组遍历填充的最后一个值,就是结果。
零钱兑换
选择:从零钱中选择一枚硬币。
状态变化分析:每选择一枚硬币,总金额就会减少该面值。
状态:总金额。
状态方程:f=dp[i] 凑出总金额为i的最少硬币数。
状态转移方程:dp[i]=min(dp[i-coins[0]],dp[i-coins[1]],...,dp[i-coins[n]])+1
base case: i<0时 dp[i]=-1 dp[0]=0
最终结果 dp[amount]
子序列问题
通常涉及到数组,字符串子序列的最值问题都需要使用动态规划。我们可以将给定的数组/字符串的子串作为状态,初始的子数组和子串可以为空或长度为1。而每次选择就是将子数组/子串前面或者后面添加一个元素,进行扩张,记录子串的最值子序列,最终得到原给定数组/字符串的最值子序列。
状态的选择可以有多种。若题目只有一个数组,可以把一端固定,只选择一个状态变量,在子数组 array[0..i] 中,我们要求的子序列的最值的 dp[i]。也可以选择两个状态变量,在子数组 array[i..j] 中,我们要求的子序列的最值为 dp[i][j]。
若题目涉及两个字符串/数组时,dp 数组的含义通常是在子数组 arr1[0..i] 和子数组 arr2[0..j] 中,我们要求的子序列的最值为 dp[i][j]
具体选择哪一种状态方程要根据选择逻辑能够找到最优子结构。
最长递增子序列
状态方程f=dp[i]表示以i结尾包含i的最长递增子序列长度。 注意:如果状态方程不一定要包含i,则找不到最优子结构,因此此题做了改造,这也是解决子序列问题的一个套路。
状态转移方程:dp[i]=max(dp[j])+1 0 base case: dp[1]=1 最终结果 dp[i]中的最大值 最长回文子序列 题目只有一个字符串,选择两个状态。 状态方程f=dp[j][k]表示字符串s从j到k的子串的最长回文子序列为f。 状态转移方程:对于一个字符串,如果收尾两个字符不想等,那么最长子序列不可能同时包含这两个字符。dp[j,k]=max(dp[j+1][k]),dp[j][k-1])。如果相等,那么dp[j,k]=dp[j+1,k-1]+2 base case: dp[i][i]=1 最终结果 dp[0][n-1] 注:如果需要输出最长回文子序列,那么dp数组存储最值长度时,顺便将对应的子序列字符串存储起来就可以了。 最长公共子序列 题目有两个字符串,选择两个状态。 状态方程f=dp[j][k]表示text1子串0...j和text2子串0...k的最长公共子串。 状态转移方程:对于两个字符串,如果他们串尾字符相等,那么dp[j][k]=dp[j-1][k-1]+1,如果不想等,那么最长公共子串不会同时包含s1[j]和s2[k],dp[j][k]=max(dp[j-1][k],dp[j][k-1]) base case: dp[j][0]=0 dp[0][k]=0 最终结果 dp[s1.len][s2.len] 编辑距离 题目有两个字符串,选择两个状态。 状态方程f=dp[j][k]表示s1子串0...j和s2子串0...k转换的最小操作数。 状态转移方程:如果s1[j]=s2[k] ,即末尾字符相等。那么dp[j][k]=dp[j-1][k-1]。如果不相等,s1可以删除dp[j][k]=dp[j-1][k]+1,修改dp[j][k]=dp[j-1][k-1]+1,添加dp[j][k]=dp[j][k-1]。取三者中的较小值。 base case: dp[j][0]=j dp[0][k]=k 最终结果 dp[s1.len][s2.len] KMP算法 注意:需要考虑匹配串为空串时的情况 假设源字符串为txt,匹配串为pat。传统算法低效的原因在于,当出现不匹配时,pat指针回退到字符串首,txt指针回退到起点位置+1。这样会出现大量重复的比较,效率很低。KMP算法的高效在于,每当不匹配时,txt指针不回退,pat指针回退到指定位置,避免了重复比较。 分析:假设txt[j]!=pat[k]。那么指针j有两种情况:1、j在txt匹配的pat子串前面,这种情况,只需要从j+1开始重新匹配 2、j在txt匹配的pat子串中,那么j在pat中的位置肯定在k之前,这样只需要将pat的指针k回退到k1重新与j比较即可。 k1如何确定: 重新比较txt[j]与pat[k1],需要保证txt[j-k1,...,j-1]==pat[0,...,k1-1],而txt[j-k1,...,j-1]==pat[k-k1,...,k-1],因此pat[0,...k1-1]==pat[k-k1,...,k-1],可以看出通过k找到k1完全取决于pat子串的结构。因此我们可以根据pat的结构构造一个pat数组next[],使得next[k]=k1,之后借助该数组在txt中查找pat,txt指针就不会回退了。 以下代码可以高效的构造next[]: public int[]KMP(String pat){ int[] next=new int[pat.length()]; //k初始化为-1,这点很重要 int j=0,k=-1; next[0]=-1; //比较j的值是为了构造next[j+1] while (j if(k==-1||pat.charAt(k)==pat.charAt(j)){ k++; j++; next[j]=k; }else { k=next[k]; } } return next; } 股票问题 选择:买入,持有,卖出。 状态变化分析:每次买入后,可交易次数-1,买入后买卖状态变为可卖,卖出后买卖状态变为可买,持有不变,无论做何种选择,天数都会+1。对于可买状态,当天只能持有和买入,对于可卖状态,当天只能持有或卖出。 状态:第几天,可交易次数,买卖状态。 状态方程:f=dp[i][k][0] 从第i天开始,还有k次交易次数,可以买入股票能获得的最大收益。f=dp[i][k][1] 从第i天开始,还有k次交易次数,可以卖出股票能获得的最大收益。 状态转移方程: dp[i][k][0]=Max(dp[i+1][k][0],dp[i+1][k-1][1]-prices[i])//第i天开始k次可买的最大利润就是要么当天不买,与第i+1天开始一样,要么买入,第i+1天开始可卖得到的最大利润减去当天股价。 dp[i][k][1]=Max(dp[i+1][k][1],dp[i+1][k][0]+prices[i])//第i天开始k次可卖的最大利润就是要么当天不卖,与第i+1天开始一样,要么卖出,第i+1天开始可买得到的最大利润加上当天股价。 注意,买入会减少次数,卖出不减。 baseCase: 最后一天开始,可买状态最大收益为0,可卖状态最大收益为当天股价。可交易次数为0时可买状态最大收益为0 最终结果 dp[0][k][0] 鸡蛋掉落 题目解析:如何理解最坏的情况。 最好的情况是仍一次,假如在第N楼仍,鸡蛋没碎,则一次就能确定F。因此,对于在第i层楼仍鸡蛋,鸡蛋碎不碎取决于哪一种情况需要确认这个F的次数更多。 选择:在第i层楼扔鸡蛋。 状态变化分析:在第i层楼扔鸡蛋,如果碎了,需要在1-i-1层中找到F,楼层N变为i-1,鸡蛋个数少一个。如果没碎,需要在i+1到N层中找到F,N变为N-i,鸡蛋个数不变。 状态:楼层N,鸡蛋个数K。 状态方程:f=dp(n,k) n楼层,k个鸡蛋,最坏条件下确认F的最少次数。 状态转移方程 dp(n,k)=min(max(dp(i-1,k-1),dp(n-i,k))) +1 1<=i<=N //尝试从1-N扔鸡蛋,化解为N个子问题,从中找到最少次数 baseCase:dp(i,1)=i //如果只有一个鸡蛋,只能从1层开始一次次尝试 dp(0,k)=0 dp(1,k)=1 进一步优化,对于状态转移方程,当n,k固定时,随着i增加,dp(i-1,k-1)单调增,dp(n-i,k),单调减少,因此两者较大值会先减少后增加,因此该值的最小值,就是dp(i-1,k-1)==dp(n-i,k)时,因此i可以从线性遍历转换为二分查找。 博弈问题(假设两个人都足够聪明,最后谁会获胜) 博弈问题的难点在于,两个人都很聪明,都尽量会让自己的得分最优,交替进行,存在先手和后手。因此状态方程的函数值存在两个。 选择:拿最左边一堆或最右边一堆 状态变化分析:当玩家拿了一堆石头后,石头堆变少。 状态:剩余的石头堆,由于石头堆用一个数组存储,剩余石头堆可以用数组的两个指针来表示。 状态方程:f1,f2=dp(i,k) i,k为石头堆数组指针,f1表示当前石头堆先手拿的最大值,f2是后手最大值。 状态方程可以改为f=dp(i,k,state) state=0表示先手 state=1表示后手。 状态转移方程: dp(i,k,0)=max(piles[i]+dp(i+1,k,1),piles[k]+dp(i,k-1,1)) dp(i,k,1)=dp(i+1,k,0) //dp(i,k,0)从左边拿 或者dp(i,k-1,0) //dp(i,k,0)从右边拿 baseCase:dp(i,i,0)=piles[i] dp(,i,1)=0 最终结果:dp(0,n-1,0)-dp(0,n-1,1) 四键键盘 选择:四种按键。 状态:剩余次数N 状态方程:f=dp(N) N为剩余按键次数 f为A的最大个数。 状态变化分析:c-a,c-c操作肯定是一起操作的,也就是复制屏幕中个数到缓存区需要消耗两次。对于任意的N,操作方式肯定是:A,A,...,A,C-A,C-C,C-V,C-V,...,C-V,...C-A,C-C,C-V,...C-V。 对于任何N,最后一次操作一定是A或者C-V。如果是A。那么 dp[N]=dp[N-1]+1。 如果是C-V,取决于最后一次C-C的位置,假设C-C位于j处。那么最后C-V按了N-j次,而每次C-V都会增加dp[i-j]次。 因此,dp[N]=dp[j-2]*(N-j+1)。 状态转移方程: 由于不知道j的位置,需要循环遍历多次尝试。 dp[N]=max(dp[j-2]*(N-j+1)) 3<=j<=N-1 baseCase: dp[0]=0 dp[i]=i 全按A 总结:此题的选择,状态都很容易找到,难点在于找到状态转移方程。通常状态转移方程都是找dp[n]与dp[n-1]之间的关系,这样分析,此题是找不到状态转移关系的,因此有时分析需要跳出这个框架,找到dp[n]与dp[1]...dp[n-1]之间的关系。