动态规划一般都是求最值问题,或者该问题的本质是求最值。
动态规划的本质是穷举,根据dp本身的定义写出状态转移方程,各个状态会有自己的范围,那就从中选出最大值,选择的方法是都试一遍找最大值。
动态规划思考与书写的流程:
1.根据题目想明白 状态 & 选择 & 结果
结果往往指的是题目要求的东西,比如说求最小操作数,那么结果就是在xxx条件之时的最小操作数;状态指的是真正能对结果产生影响的变量,状态的理解与设置往往决定了一个动态规划的基础,题目中会展示一些变量,我们可以在此基础上进行思考,但二者并不是完全一致;选择则是在每个状态下拥有的不同结果(选与不选,哪一种特定操作,或者是不同的结果,这里对于不同的结果理解上会不舒服,因为选择是相对主动的操作,而结果是相对被动的事情,但其实本质上是一样的,因为对dp的每一个状态,都要去穷举其各个情况,所以这里可以认知为“穷举各种可能”)。
2.写出dp的定义 (主要利用状态和结果,在xxx状态下,结果为uuu)
dp往往有两种,dp[] 数组 或者 dp函数,但本质上是一样的,dp[] 往往用于自底向上的定义,写起来简单,缺点是会有索引偏移,dp函数是用于带有备忘录的自顶向下的定义,写起来复杂一点,一般来说,同一个dp定义,这两种都可以写出来,只不过自底向上是[0.........i] 的情况,最终输出dp[n] 的值,而自顶向下的是[i........末尾]的情况,最终输出dp(0)。这一步最关键的在于自己要能用自然语言解释dp定义,比如“在可用鸡蛋为k,搜索的目标范围为n的情况下所需的最小操作数”,这样方便与写出状态转移方程。
3.写出状态转移方程 (利用选择)
这是在dp定义好之后,根据dp的定义和选择,写出状态转移方程,大概是dp[x] = max (选择1,选择2,选择3) = max(dp[x-1], dp[x-2]+2..)
这里是最关键的一步,虽然思考的过程应该在上一步就完成。
4.写出base case
base case就是那些不用算,仅靠逻辑分析就可以直接得出结果的极端情况,用于启动整个动态规划的运算。写全base case还是需要一点经验。
5.穷举
根据base case和状态转移方程写出穷举的方式。这里其实有两种,第一种是递归模式,也就是把所有的都丢进递归,最终由base case开始运算,另一种是状态遍历,即
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1] [状态2] [...] = 择优(选择1,选择2...)
这里的择优也就是max,min.....,可以画个图来看应该如何遍历,要确保状态转移方程需要的条件是足够的,dp[i][j]
与dp[i+1][j-1]
,dp[i+1][j]
,dp[i][j-1]
都有关系的话,那这三个方向都得知道才能继续往下算。
一些经验:
1思考的时候要想明白,状态转移方程是为了计算,即是为了从已知得到未知,以自底向上为例:找到的关系式一定是[i] [j] = 择优([i-1] [j-1] , [i] [j-1], [i-1] [j] ),一定是按照顺序(自底向上还是自顶向下)的已知到未知。
2两种穷举模式,都是首先设定base case,状态遍历时用for直接从base case开始往下计算,递归则是写出状态转移方程,这里面自然是包含递归的,依次递归到base case,再自动算回来。
3最关键的是定义dp与找到状态转移方程,找到之后,若为自底向上则设数组直接写,但可能为了base case要索引偏移;若是自顶向下,那么就设函数dp,函数dp有时候比数组更好操作!!!,这里往往会设置一个备忘录,memo,其实memo[i] [j] 就是记录dp[i] [j] 的值。
int[][] memo=new int[m][n];
Arrays.fill(memo); //给memo设上特殊值,特殊值一定要在正常值的取值范围之外,表示该处的dp没有计算
int dp(int x,int y){
//memo在该处不是特殊值,就表示已经计算过了,直接返回
if(memo[i][j]!=特殊值){
return memo[i][j];
}
//在该处是特殊值,说明未计算过,那么就需要用状态转移方程来计算
return 状态转移方程;
}