什么是动态规划?
引用 leetcode 的一段话,我认为它讲很权威,我将结合实战带你学习动态规划。
看得很懵吧?懵就对了,我当初接触动态规划的时候,也懵了很久。但是,只有我们搞清楚以下几个问题,动态规划其实也不是那么的难。(三维四维DP难到怀疑人生QAQ)
- 状态的定义
- 状态转移方程(数学归纳法)
- 初始条件和边界
还是有点懵?懵就对了,我详细解释一下。
状态的定义
对于状态的定义,其实就是找题目给定的条件,限制的条件。就拿经典的 爬楼梯 来举例。
首先我们收集题目限制的条件,一个是每次只可以爬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] 表示手上的股票已经卖出去了,很显然后者得到的利润一定大于前者。
状态转移方程(数学归纳法)
最关键的步骤,也是难点之一,但是对于状态转移方程,我们可以根据状态的定义,转变得出,仔细观察上面的状态转换图,买卖股票的操作,我们可以得出持有股票,或未持有股票的两个状态转移。如果还不明白,回头看买卖的状态图。
- 未持有股票:之前就没有,可以rest;或者之前就持有,我现在卖了。
- 已持有股票,之前就持有,可以rest,或者之前未持有,我现在买入。
根据这个状态的转换,我们就可以得出状态转移方程,也就是数学归纳法得出通用公式,[ 这里要注意卖出股票会获取利润,买入股票需要支付成本的问题 ]。为什么 k-1 呢?因为当我们买入一次股票之后交易次数就要减 1。
- dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
- 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])
- dp[0][k][0] = max(dp[-1][k][0], dp[-1][k][1] + prices[i]) { i = 0 }
- 其中 dp[-1][k][0],不管k是多少次,股票第 i 天都是 -1 ,也就是还没开始呀,0才是开始,所以 dp[-1][k][0] = 0;
- 再看 dp[-1][k][1],都没有开始,你就持有股票了,咋可能呢?因为未持有是0,我们就用负无穷表示未开始持有股票的值,即 -Infinity
- 所以 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])
- dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]) { i = 0 }
- 同理 dp[i-1][k][1] 为 -Infinity,dp[i-1][k-1][0] 为 0
- 所以 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库。所以,算法真的很有用,扎实编码功底的最佳选择。
另外,推荐阅读大神的算法小抄,链接。