讲一讲最近很喜欢的动态规划,语言是用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: 买卖股票含手续费(二维动态规划)
同样,我们首先定义动态转移方程。很明显,在这个题里面,当前状态转移到下一个状态,对应的是题目里时间过去了一天,但是由于题目的限制,我们不能确定今天是否要购买股票,那么要怎么定义状态转移方程呢?
仔细观察题目,在每一天我们其实有两种状态:持有股票和未持有股票,我们可以采取一种笨办法,把这两种状态全部保存下来。具体保存方式如下:
> 用一个二维数组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: 买卖股票限制交易次数(三维动态规划)
这题和上一道题本质区别不大,难点在于增加了交易次数的限制。对于天数和交易状态(持有和未持有),可以采用上题一样的方法定义。对于交易次数,可以再增加一维数组,用来表示当前交易过几次。
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. 对前一个阶段的所有可能性进行剪枝。例如股票问题中,我们追求最大利润,就取前一天两种可能性中的较大值即可。
动态规划前期基本都会经历做不出来题的阶段。这时候不用死磕,把别人的回答理解透彻,做得多了慢慢就会习惯动态规划这种思维方式了。