动态规划,我不再怕了。

什么是动态规划?

引用 leetcode 的一段话,我认为它讲很权威,我将结合实战带你学习动态规划。java


看得很懵吧?懵就对了,我当初接触动态规划的时候,也懵了好久。可是,只有咱们搞清楚如下几个问题,动态规划其实也不是那么的难。(三维四维DP难到怀疑人生QAQ)git

  1. 状态的定义
  2. 状态转移方程(数学概括法)
  3. 初始条件和边界

仍是有点懵?懵就对了,我详细解释一下。算法

状态的定义

对于状态的定义,其实就是找题目给定的条件,限制的条件。就拿经典的 爬楼梯 来举例。typescript

首先咱们收集题目限制的条件,一个是每次只能够爬1个台阶,或者2个台阶。另一个是须要n阶到达楼顶;那么咱们状态的定义确定只和这两个条件有关系。这不是废话嘛,这真不是废话,有时候解题的关键点之一就是在于找准状态的定义数组

那这题的状态的定义该怎么定义呢?很明显就是爬n个台阶到达楼顶的不一样方法。bash

dp = 爬到楼顶的不一样方法markdown

状态转移方程(数学概括法)

转移方程,也是dp的难点之一,仍是继续以爬楼梯为例;其实dp最难也就是前两点了;把状态的定义状态转移方程肯定以后,dp也就迎刃而解了。oop

其实状态转移方程也就是数学概括法,听着很高大上吧?其实它就是找规律而已。学习

关键是怎么找规律呢?授之以鱼不如授之以渔。编码

直接根据求解的问题,拆分红子问题,规模更小的问题来思考;好比爬楼梯,爬n台阶很懵吧?我相信没有接触过dp的同窗都会很懵,别怕,咱们从规模小的问题来思考,获得结果,从而递推出规律,也就是状态转移方程;

  • n=1,结果为1
  • n=2,结果为2
  • n=3,结果为3
  • n=4,结果为5
  • ........

仔细观察上面的数据,由问题的规模不断的变大,结果也会逐渐的变大,那它们有什么规律呢?细心观察的小伙伴确定会发现 f(n) = f(n-1) + f(n-2),这不就是最熟悉的斐波那契数列问题了吗?到这里,状态转移方程就是 f(n) = f(n-1) + f(n-2) { n > 2 }

初始条件和边界

咱们把状态的定义和状态转移方程肯定以后,初始条件就很简单了,就直接观察状态转移方程知足条件的起始值,其实就是 n > 2,当知足这个条件的时候,公式才成立,不知足公式的条件,就是初始条件或者边界,也就是当 n <= 2 的结果,就是初始条件;咱们很容易就能够想出来 f(1) = 1, f(2) = 2;有时候相对难一点的dp,须要利用状态转移方程来肯定,后面解释。

代码模版

得出dp三个须要肯定的条件以后,咱们就能够根据这三个条件来写代码了

var climbStairs = function(n) {    
  // 状态:dp = 爬到楼顶的不一样方法 
  // 边界: fn(1) = 1,fn(2) = 2 
  // 动态方程: fn(n) = fn(n-1) + fn(n-2) 
  if(n < 3) return n    
  let fn_1 = 1    
  let fn_2 = 2    
  let res = 0   
  for(let i = 3; i <= n; i++){ 
    res = fn_1 + fn_2  
    fn_1 = fn_2        
    fn_2 = res  
  }   
  return res
};复制代码

经典的股票系列问题

当你彻底弄懂了股票系列问题,你才算得上真正的入门动态规划问题。在此以前,有个大神的文章写得很是好,可是是java版本的,我在学习的过程当中也发现了一点小错误,挣扎了好久,弄明白以后写下此文,对动态规划学习作一个总结。我但愿你能先认真的过一边原文,原文很长,你必须耐心的看懂里面讲的问题,无需纠结里面的细节,下面我将带领你解决这些细枝末节的东西,真正的入门动态规划。原文连接

股票问题状态定义

我假设你已经看过原文,大概弄懂了做者讲什么,先来复习看这张状态图,有的小伙伴确定会问?他是怎么获得这张状态定义图的?上面爬楼梯我说过了,是根据题目的限制条件,抽取出来的。


  • 1 表明持有股票,只能选择rest操做,或者sell卖掉股票
  • 0 表明未持有股票,只能选择rest操做,或者buy买股票

以上两点就是最关键的状态定义,同时结合能够交易的次数k,和第i天的股票价格,状态的具体定义能够这么来:

  • dp[i][k][1]:今天是第 i 天,我如今手上持有着股票,至今最多进行 k 次交易。
  • dp[i][k][0]:今天是第 i 天,我如今手上未持有着股票,至今最多进行 k 次交易。

很显然,咱们想求的最终答案是 dp[n - 1][K][0],即最后一天,最多容许 K 次交易,最多得到多少利润。为何不是 dp[n - 1][K][1]?由于 [1] 表明手上还持有股票,[0] 表示手上的股票已经卖出去了,很显而后者获得的利润必定大于前者。 

状态转移方程(数学概括法)

最关键的步骤,也是难点之一,可是对于状态转移方程,咱们能够根据状态的定义,转变得出,仔细观察上面的状态转换图,买卖股票的操做,咱们能够得出持有股票,或未持有股票的两个状态转移。若是还不明白,回头看买卖的状态图。

  1. 未持有股票:以前就没有,能够rest;或者以前就持有,我如今卖了。
  2. 已持有股票,以前就持有,能够rest,或者以前未持有,我如今买入。

根据这个状态的转换,咱们就能够得出状态转移方程,也就是数学概括法得出通用公式,[ 这里要注意卖出股票会获取利润,买入股票须要支付成本的问题 ]。为何 k-1 呢?由于当咱们买入一次股票以后交易次数就要减 1

  1. dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
  2. dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])

到这里,状态转移方程就写出来了,难点在于考虑买卖股票的状态图,根据状态机得出转移方程,不过,我认为这都是熟练度的问题,你只要把dp的本质理清楚,剩下的就是多练了。

初始条件和边界

股票问题的初始条件和边界,会有点隐晦,不像爬楼梯那么直白明了。我上面也说过,比较难的咱们能够经过状态转移方程直接代入进去,而后得出,咱们来尝试一下。

  • dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])

  1. dp[0][k][0] = max(dp[-1][k][0], dp[-1][k][1] + prices[i]) { i = 0 }
  2. 其中 dp[-1][k][0],无论k是多少次,股票第 i 天都是 -1 ,也就是还没开始呀,0才是开始,因此 dp[-1][k][0] = 0
  3. 再看 dp[-1][k][1],都没有开始,你就持有股票了,咋可能呢?由于未持有是0,咱们就用负无穷表示未开始持有股票的值,即 -Infinity
  4. 因此 dp[i][k][0] = max(0, -Infinity + prices[i]) = 0
  • dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
  1. dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]) { i = 0 }
  2. 同理 dp[i-1][k][1] 为 -Infinity,dp[i-1][k-1][0] 为 0
  3. 因此 dp[i][k][1] = max(-Infinity, 0 - prices[i]) = -prices[i]

综上所述,咱们就能够得出初始条件和边界值了,即当 i - 1 == -1的时候

  • dp[-1][k][0] = 0
  • dp[-1][k][1] = -prices[0]

当 k= 0 的时候,也就是还未交易,原理也是同样的

  • dp[i][0][0] = 0
  • dp[i][0][1] = -prices[0]

代码模版

  • 设三维数组 dp[n][k+1][2],n,k+1,2均为当前维度数组元素个数,有的小伙伴会问为何是k+1,n呢?而不是k,n-1。由于 dp 须要初始值递推,因此要多取一个元素。
  • i 为天数,m为最大交易次数,0或1为交易状态;且 0 <= i < n ,1 <= m <= k
  • 为何有的状态枚举是正着来?有的是反着来?其实两个均可以,你只须要记住,遍历的过程当中,所需的状态必须是已经计算出来的;遍历的终点必须是存储结果的那个位置便可。

const maxProfit = function(k, prices) {
    // 交易天数
    let n = prices.length;
    // 最大交易次数,k不影响状态转移方程,此处去掉
    let maxTime = k;
    if(n == 0){
        return 0;
    }
    // 初始化三维数组
    // 若是当题 k 不影响状态转移方程,此处初始化去掉
    let dp = Array.from(new Array(n),() => new Array(maxTime+1));
    for(let i = 0;i < n;i++){
        for(let r = 0;r <= maxTime;r++){
            dp[i][r] = new Array(2);
        }
    }
    // 若是当题k不影响状态转移方程,则只需二维数组
    // let dp = Array.from(new Array(n),() => new Array(2));

    // 枚举递推
    for(let i = 0;i < n;i++){
        // 若是当题k不影响状态转移方程,内循环去掉
        for(let k = maxTime;k >= 1;k--){
            if(i == 0){
                // 边界条件处理
                continue;
            }
            // 递推公式,上面分析的状态转移方程
            dp[i][k][0] = Math.max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])   
            dp[i][k][1] = Math.max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
        }
    }
    // 返回结果
    return dp[n-1][maxTime][0];
    // 若是当题k不影响状态转移方程返回此结果
    // return dp[n-1][0];
};复制代码

秒掉六道股票问题

121. 买卖股票的最佳时机122. 买卖股票的最佳时机 II123. 买卖股票的最佳时机 III188. 买卖股票的最佳时机 IV309. 最佳买卖股票时机含冷冻期714. 买卖股票的最佳时机含手续费

第一道

121. 买卖股票的最佳时机


第二道

122.买卖股票的最佳时机 II


第三道

123. 买卖股票的最佳时机 III


第四道

188.买卖股票的最佳时机 IV 

第五道

309. 最佳买卖股票时机含冷冻期


第六道

714. 买卖股票的最佳时机含手续费


总结

dp问题须要多练,简单的一维二维还好,难度不大,其中三维思惟就很考验熟练度和数学建模的抽象能力了,不得不认可有些是天赋型选手,咱们普通人,多练就好。

其实练算法最大的好处,就是提高编码能力。我在半年以前一点都不懂算法的,写业务也会偶尔卡壳,以前遇到一个排列组合的业务问题也彻底懵逼。在通过半年的思惟提高之后,业务代码我彻底能够切菜式的完成,并且代码写得也比以前要好不少。对于困难一点,复杂一点的组件封装,也可以封装得很好。甚至,能够本身封装一个UI库。因此,算法真的颇有用,扎实编码功底的最佳选择。

另外,推荐阅读大神的算法小抄,连接。

相关文章
相关标签/搜索