动态规划

通常求最值的问题会用到动态规划。

动态规划涉及到三个特性,重叠子问题,最优子结构和状态转移方程。解决动态规划问题本质就是列出状态转移方程,一旦列出状态转移方程,便可以用蛮力法穷举得到结果。由于动态规划问题存在重叠子问题的特征,因此蛮力法计算过程中会出现大量重复的计算,动态规划只是将重复的计算结果缓存了下来,下次遇到重复计算用查表来代替执行,从而加快执行效率。而列出动态转移方程的关键在于找到最优子结构。

解题思路

首先,我们要找到问题的所有状态列出状态方程。即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]之间的关系。

你可能感兴趣的:(动态规划)