动态规划,我再也不怕了。

3,713 阅读10分钟

什么是动态规划?

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


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

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

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

状态的定义

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

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

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

dp = 爬到楼顶的不同方法

状态转移方程(数学归纳法)

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

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

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

直接根据求解的问题,拆分成子问题,规模更小的问题来思考;比如爬楼梯,爬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库。所以,算法真的很有用,扎实编码功底的最佳选择。

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