动态规划入门与基本解题思路

776 阅读13分钟

讲一讲最近很喜欢的动态规划,语言是用JavaScript来写的,以后的文章可能也会多以前端为主。

概述

动态规划是四种经典的算法思想之一,它不是一种具体的算法,而是提供一种解题思路。很多人觉得动态规划入门很难,是四种思想里面最难理解的,我最初也这么感觉,但是掌握之后却又觉得还好。动态规划之所以让人难以理解,主要是它和我们平时习惯的那种思维方式不太一样,而且一开始接触的各种复杂的名词也容易让人晕头转向。

所以在这篇文章里,我想完全抛开定义,用最通俗的语言和例子去解释,这样对于入门理解可能更友好一些。不过建议你看完后,还是去找找其他文章更深入系统地了解一下。

什么是动态规划

先暂时用一种容易理解但不准确语言来描述:动态规划就是把一个大问题按顺序分解成多个思路相同的小步骤,并且每一个步骤都去追求最优化的解,一步步推断到最后,就可以得到想要的结果。

举个最简单的例子: > 我们正站在一段楼梯的最下方,想要知道一个楼梯有多少层应该怎么办?

动态规划的思路会着重关注临近的两个状态是如何变化的。对应到这个例子,就是我们只关注怎么从当前的阶梯移动到下一个阶梯。假设我们最初的位置是第0个楼梯,然后我们可以轻易地想到,每当我们向上走一步,我们所处的位置就是在前一层的基础上+1。

在这里,我们可以得到一个方程式:

let cur = 0; // 当前所处楼层
let next = cur + 1; // 下一步所处的楼层

只要我们按照这个方程一步步走下去,到楼梯顶上的时候自然知道有多少阶了。用代码表示就是:

const stairs = 10; // 假设有10层
function getStairs() {
  let cur = 0; // 当前所处楼层
  while(cur < stairs) {
    let next = cur + 1; // 根据当前状态得出下一个状态
    cur = next; // 把当前状态切换到下一个状态
  }
  return cur;
}

这个例子可能太简单了,不容易找到感觉。我们在此基础上让问题稍微复杂一点。

示例1:爬楼梯打怪物

> 假设这个楼梯每一层都有一只怪物,你必须打死这只怪物才能继续往上爬,而打死这只怪物需要花费不等的血量。我们用一个数组,表示打死这只怪物需要花费多少血量,数组的下标i对应第i + 1层楼梯。 > > 打死怪物后,我们可以选择爬一步或者两步。问爬到顶最少需要耗费多少血量(注意这个问题里面,爬楼梯的动作是在花费血量之后)?

// 比如下标为1的位置值是15,表示打死第2层的怪物需要花费15的血量
const costs = [10, 15, 20];

也许你会想到用贪心的思路去做,也就是从两格阶梯之内选择花费力气较少的那个,不过这是错的。例如[0, 1, 2, 3]这个数组,用贪心的思路是这样的: - 0和1之间选择0,后面两层是1和2 - 1和2之间选择1,后面两次是2和3 - 2和3之间选择2 - 到顶 这样会选择0 - 1 - 2 到顶的路线,但其实0 - 2 到顶的路线才是花费最小的。

这里说一下用动态规划该怎么做。

我们可以把思路转换一下,可以从顶层反推一下。我们到顶的时候只有两个选择,要么从倒数第一个楼梯爬上来,要么从倒数第二个楼梯爬上来,而花费最少的方案,必然是在两个楼梯里面选择花费较少的那个。那么对于其他的楼梯,是不是也适用于这个思路呢?答案是肯定的。

根据上面的思路,这个问题就可以看作我们爬上第i层的时候,从i - 1和i - 2层里面选择耗费血量较少的那个,这样就可以找出前进时的规律(即列出状态转移方程):

dp[i] = Math.min(
  dp[i - 1] + costs[i - 1], // 上来之前要加上那一层的花掉的血量
  dp[i - 2] + costs[i - 2]
)

同时我们可以用一个数组,把每一步的最优解都保存下来,这样求解下一步的最优解的时候,会从上一步的最优解中取值,保证每一步得到的都是最优解。

var minCostClimbingStairs = function(cost) {
  // 用一个数组保存到达每一层花费的总和
  const dp = []; 
  // 前两层无法用方程推导出来,手动初始化一下
  // 前两层都可以从初始位置爬上来,不用击杀怪物,所以花费都是0
  dp[0] = 0;
  dp[1] = 0;

  let i = 2;
  // 注意必须位于所有阶梯之上才算到顶,所以这里要
  while(i < cost.length + 1) {
      dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
      i++;
  }
  
  // 返回到顶时的花费即可
  return dp[dp.length - 1];
};

记住动态规划的关键,就是把大问题分解成一个个小问题,找出怎么根据前一个状态推算出当前状态的方程——既状态转移方程,并且确认后面的步骤都可以按照这个方程来进行,这样就能推出正确的结果。

后面我会分析几道leetcode的动态规划题,你可以试着自己到网站上做下找找感觉。

示例2: 买卖股票含手续费(二维动态规划)

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

同样,我们首先定义动态转移方程。很明显,在这个题里面,当前状态转移到下一个状态,对应的是题目里时间过去了一天,但是由于题目的限制,我们不能确定今天是否要购买股票,那么要怎么定义状态转移方程呢?

仔细观察题目,在每一天我们其实有两种状态:持有股票和未持有股票,我们可以采取一种笨办法,把这两种状态全部保存下来。具体保存方式如下:

> 用一个二维数组dp进行保存
> dp[i][0] 表示第i天未持有股票
> dp[i][1] 表示第i天持有股票

在状态转移时(股票买卖从第一天进行到第二天),可能产生四种结果

- 前一天未持有,第二天未持有,无操作 - 前一天持有股票,第二天也持有股票,无操作; - 前一天未持有,第二天持有,说明买了今天的股票; - 前一天持有,第二天未持有;说明股票今天被卖掉了;

针对前两种情况,可列出状态转移方程:

// 没有进行任何操作,直接简单赋值即可
dp[i][0] = dp[i - 1][0]
dp[i][1] = dp[i - 1][1];

后两种情况稍微复杂一点:

// 前一天持有,第二天未持有;说明股票今天被卖掉了;
// 股票卖掉,我们收入增加(加上当天的股票价格),再减去手续费,就是今天的收入
dp[i][0] = dp[i - 1][1] + prices[i] - fee;
// 前一天未持有,第二天持有,说明买了今天的股票;
// 买了股票,我们收入减少(减去当天的股票价格)
dp[i][1] = dp[i - 1][0] - prices[i];

dp[i][0]和dp[i][1]虽然都分别存在两种可能,但我们分析题目可知,我们需要追求最大利润,因此只需要取两种可能中的较大值即可。这样就能得出最终的状态转移方程:

dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i] - fee );
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]);

剩下的就很简单了:

var maxProfit = function(prices, fee) {
  let dp = Array.from({length:prices.length}).map(i=>[]);
  // dp[i][j] i: 第i天,j: 0未持有,1持有
  dp[0][0] = 0;
  dp[0][1] = -prices[0];

  for(let i = 1; i < prices.length; i++) {
    let pre = dp[i - 1];
    dp[i][0] = Math.max(pre[0], pre[1] + prices[i] - fee );
    dp[i][1] = Math.max(pre[0] - prices[i], pre[1]);
  }
  
  // 返回最后一天未持有股票的状态,必然就是最大利润
  // 不然你还留着股票干啥?
  return dp[dp.length - 1][0];
}

示例3: 买卖股票限制交易次数(三维动态规划)

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

这题和上一道题本质区别不大,难点在于增加了交易次数的限制。对于天数和交易状态(持有和未持有),可以采用上题一样的方法定义。对于交易次数,可以再增加一维数组,用来表示当前交易过几次。

ps.分别完成一次买和卖的操作才能算作一次交易。

> dp[i][0][j] 表示第i天未持有股票,并且进行过j次交易
> dp[i][1][j] 表示第i天持有股票,并且进行过j次交易

虽然第i天交易j次可能有很多种方式,但利润最大的方式必然只有一种。我们把第i天交易0次、1次、2次……k次的最大利润全部保存起来,然后遍历最后一天所有的交易次数,看哪个利润最大,就可以得到正确答案了。

思路有了,状态转移方程该如何设计呢?可以仿照上题的思路思考:

> 如果第二天未持有股票,完成了j次交易,那么前一天只会有两种可能; >> 前一天持有股票,说明今天卖掉了股票,那么前一天的交易次数为j - 1;
>> >> 前一天未持有股票,无操作变化,交易次数为j; > 如果第二天持有股票,完成了j次交易,那么前一天也只会产生两种可能; >> 前一天持有股票,无操作变化,交易次数为j;
>> >> 前一天未持有股票,说明第二天买入了股票,不过交易次数仍然为j,因为单纯的买入不算做一次完整交易;

再在两种可能性中取利润较大的那个,就可以分析得出状态转移方程:

dp[i][0][j] = Math.max(dp[i - 1][0][j], dp[i - 1][1][j - 1] + prices[i]);
dp[i][1][j] = Math.max(dp[i - 1][1][j], dp[i - 1][0][j] - prices[i]);

状态转移方程找出来了,但在最后的实际实现中,还有两个要点要注意。

1. 由于交易次数受天数限制,并且一次完整的交易至少需要两天,所以实际交易次数小于等于Math.min(k, 天数/2)。 2. 有时候会出现不合法的交易次数(比如第0天不可能有交易次数),所以对于不合法的交易次数,需要初始化一个无限小的值防止出错。 3. 每天0次交易的情况需要特殊处理。

最后附上完整代码:

var maxProfit = function(k, prices) {
    if(!prices.length) return 0;

    const n = prices.length;
    // 最大交易次数
    const x = Math.min(k, n / 2);
    const dp = Array.from({length: n}).map(
        i=> Array.from({length:2}).map(i=>[])
    )

    dp[0][0][0] = 0;
    dp[0][1][0] = -prices[0];
    
    // 给不合法的交易次数初始化一个无限小的数
    for(let i = 1; i < x + 1; i++) {
        dp[0][0][i] = dp[0][1][i] = -Infinity;
    }

    for(let i = 1; i < n; i++) {
    	// 0次交易的情况需要特殊处理
        dp[i][0][0] = 0; // 若在数组初始化的时候填充0,可省略该行代码
        dp[i][1][0] = Math.max(dp[i - 1][1][0], -prices[i])
        for(let j = 1; j < x + 1; j++) {
            dp[i][0][j] = Math.max(dp[i - 1][0][j], dp[i - 1][1][j - 1] + prices[i])
            dp[i][1][j] = Math.max(dp[i - 1][1][j], dp[i - 1][0][j] - prices[i]);
        }
    }
	
    // 返回最后一天未持有股票状态下的所有交易次数中,数值最大的那个
    return Math.max(...dp[n - 1][0])
};

三维的数组相对复杂,写起来容易出错,我们可以对代码进行一些优化。比如股票的持有与否只存在两种情况,这种状态比较少的情况我们可以直接用两个变量替代。详细代码可参考官方题解。

总结

本文简单介绍了动态规划的概念,但是动态规划也有其局限性,并不适用于所有问题。关于这方面的详细理论,可以参考极客时间王争老师的文章

这里简单介绍下我个人总结的动态规划解题套路: 1. 思考题目,找出题目中可能参与状态变化的数据,据此定义动态规划数组 2. 找出状态转移方程(怎么根据前面的状态推导出后面的状态) 3. 定义初始值(比如dp[0]一般都是需要自己定义的)

根据个人经验,第2步往往是最难的,其次是第一步。即便初步掌握了动态规划,我自己刚开始做题还是容易懵逼,就是因为想不出状态转移方程。可以按照这样的思路进行思考:

1. 首先搞清楚每一个阶段的状态里面,有哪些变量。例如在股票的例子里面,每天的变量有利润、天数、交易次数等。根据变量的数量基本可确定需要几维的动态规划。

2. 用一般性的逻辑思维去思考,每个阶段(每天)的状态有几种可能情况。例如在二维动态规划的题里面,只有两种情况,持有股票和未持有股票;在三维动态规划的题里面,可能的情况有 k(交易次数) * 2(持有和未持有)种。

3. 思考每一种状态是如何由前一天的状态转变过来的。例如股票的每一种状态都由前一天的两种状态转移过来。

4. 对前一个阶段的所有可能性进行剪枝。例如股票问题中,我们追求最大利润,就取前一天两种可能性中的较大值即可。

动态规划前期基本都会经历做不出来题的阶段。这时候不用死磕,把别人的回答理解透彻,做得多了慢慢就会习惯动态规划这种思维方式了。