动态规划
- 面临着一种选择
- 进行选择后会产生什么样的结果,每种结果会带来何种子问题
- 子问题的空间是什么样的
- 如何刻画子问题空间并解决子问题
- 状态机。
- 暴力搜索法考虑所有可能产生的情况选择最优解
- 许多种情况是数以一种状态的
- 动态规划考虑所有可能的状态。考虑状态之间是如何转换的
- 记录所有状态转换的结果
股票买卖的六道题
不限制交易次数k
不限制交易次数k
限制交易次数k
k=1
k=2
k任意
附加条件
含有冷冻期
含有交易费用
思路,困难,进阶
- 暴力法与动态规划法的区别
- 如何刻画问题所含有的状态
- 如何确定完成一次交易
- 如何得到状态转换方程
- 如何进行状态压缩
- 正序,反序有何区别
- 初始化与输出对算法的影响
- 有哪些提高算法时空复杂度的小诀窍
暴力法解决与动态规划
- 暴力法求解最优问题时,一般把问题所产生的所有情况求解求取最优的
- 然而许多种情况其实都处于同一种状态
- 比如股票问题
- 我们可以将其刻画为
动态规划应用状态机
- 定义状态
- 如何刻画状态转移方程
- 初始化
- 输出
- 压缩状态
适用于所有股票买卖的方法
对于股票买卖,我们手头上就只有两种状态持有股票或者不持有股票
对应于股票状态之间的变换就是卖出,买进还是无操作
- 当前未持有股票:买进 -> 持有股票 ; 无操作 -> 未持有股票
- 当前持有股票: 卖出 -> 未持有股票;无操作 -> 持有股票
得到如下基本状态转换图
不限制交易次数k k = +infinity
持有股票为状态1,未持有为状态0
// 初始化
dp[0][0] = 0; // 第一天未持有股票
dp[0][1] = -prices[i]; // 第一天持有股票:花费
// 状态转换
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])
// 输出
dp[len-1][0]
限制交易次数
只能进行一次交易 k = 1;
未持有的状态有两种情况:尚未买入(未开始交易),已经卖出(无法买入)
0 为未持有(未买入股票), 1 为持有(买入股票), 2 为未持有(买入后卖出)
// 初始化
dp[0][0] = 0;
dp[0][1] = -prices[0]; // 买入股票花费金额
dp[0][2] = 0; //当天买入后卖出获益0
// 状态转换方程
dp[i-1][0] = 0;
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i]) // 第i天持有股票的收益
dp[i][2] = max(dp[i-1][2], dp[i-1][1] + prices[i]) // 第i天未持有的收益
// 输出
dp[len-1][2]
对上述状态转换的矩阵能否进行状态压缩减少存储空间呢
- 状态0始终为0
dp[i][1] = max(dp[i-1][1], 0 - prices[i]) // 第i天持有股票的收益
dp[i][2] = max(dp[i-1][2], dp[i-1][1] + prices[i]) // 第i天未持有的收益
- 状态1当天的最佳收益需要状态1前一天的最佳收益和前一天状态0-当天购入股票的花费
- 状态2当天的最佳收益需要状态2前一天的最佳收益和前一天状态1-当天购入股票的花费
- 状态2当天最佳收益的答案需要状态1前一天和其本身前一天的。而状态1仅需要自身前一天的最佳收益
- 我们将状态1和状态2调整顺序
- 此时我们只需要两个变量保存状态1和状态2前一天的最佳收益
dp_not_hold = max(dp_not_hold, dp_hold + prices[i]) // 第i天未持有的收益
dp_hold = max(dp_hold, - prices[i]) // 第i天持有的收益
交易次数 k = 2;
// 初始化
dp[0][0] = 0
dp[0][1] = -prices[0]
dp[0][2] = 0
dp[0][3] = -infintiny; // 一定要设置一个不可能的数,为什么呢,我们后续分析一下
dp[0][4] = 0
// 状态转换方程
dp[i][0] = 0 // 未开始交易
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i]) // 第一次买入
dp[i][2] = max(dp[i-1][2], dp[i-1][1] + prices[i]) // 第一次卖出
dp[i][3] = max(dp[i-1][3], dp[i-1][2] - prices[i]) // 第二次买入
dp[i][4] = max(dp[i-1][4], dp[i-1][3] + prices[i]) // 第二次卖出
// 输出
rst = max(dp[len -1][2], dp[i-1][4]) //所有最后卖出的情况都应该考虑(进行一次交易,进行两次交易)
思考以下两个问题
- problem 1 : 为什么一定要设置状态三的数字为无穷小(不可能的数)
- ans 1
- 如果第二次买入不设置为负无穷,可能或导致第二次卖出得到一个本不应该得到的数据
- 思考第二天 [1, 3, 2] 若状态3初始化为0
- 理论上来讲第二天的第二次卖出的数据最高只能为0;第一天当天买进当天卖出,第二天当天买进当天卖出
- dp[1][4] = max(0, 0 +3) = 3理论上应为0但是却被置为了3
- problem 2 : 也能进行状态压缩么?
- ans 2
- 根据前面的分析我们同样应用到这里
- 如果我们逆序输出就只需要保存一维数据
C ++ 代码如下:
- O(n)时间复杂度
- O(1)空间复杂度
dp[4] = max(dp[4], dp[3] + prices[i]); // 第二次卖出
dp[3] = max(dp[3], dp[2] - prices[i]); // 第二次买入
dp[2] = max(dp[2], dp[1] + prices[i]); // 第一次卖出
dp[1] = max(dp[1], - prices[i]); // 第一次买入
但我在LeetCode上提交代码时候正序也能输出
int maxProfit(vector<int>& prices) {
int dp[5] = {0,0,0,0,0};
int len = prices.size();
int rst = 0;
if(len >1)
{
dp[1] -= prices[0];
dp[3] = -99999999;
for(int i=1; i<len; ++i)
{
dp[0] = 0;
dp[1] = max(dp[1], dp[0] - prices[i]); // 第一次买入
dp[2] = max(dp[2], dp[1] + prices[i]); // 第一次卖出
dp[3] = max(dp[3], dp[2] - prices[i]); // 第二次买入
dp[4] = max(dp[4], dp[3] + prices[i]); // 第二次卖出
}
rst = max(dp[2],dp[4]);
}
return rst;
}
- 为什么呢?到后面一起解决
如果交易次数k是任意给定的呢(k = 3,4,5,6,7,8)也要设定无数种状态么
k 任意
- 我们看一下k = 2 时候的状态转换图
- 其中每次买入和卖出的操作都是重复的唯一变化的就是当前所处于的是第几次的交易
- 因此我们可以建立一个三维DP数组
dp[i][k][2] 表示(0,i)天剩余交易次数为k时状态为持有(1)未持有(0)的最大收益
以 k = 2来看可以改写为如下
dp[i][0] = 0 // 未开始交易
dp[i][2][0] // 剩余交易次数为2次
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i]) // 第一次买入
dp[i][1][1] = max(dp[i-1][1][1], dp[i-1][2][0] - prices[i]) // 剩余交易次数为1
dp[i][2] = max(dp[i-1][2], dp[i-1][1] + prices[i]) // 第一次卖出
dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][1][1] + prices[i]) // 剩余交易次数为1
dp[i][3] = max(dp[i-1][3], dp[i-1][2] - prices[i]) // 第二次买入
dp[i][0][1] = max(dp[i-1][0][1], dp[i-1][1][0] - prices[i]) // 剩余交易次数为0
dp[i][4] = max(dp[i-1][4], dp[i-1][3] + prices[i]) // 第二次卖出
dp[i][0][0] = max(dp[i-1][0][0], dp[i-1][0][1] + prices[i]) // 剩余交易次数为0
对应关系如下:
dp[i][0] = dp[i][2][0] // 未开始交易 - 未持有股票0: 剩余交易次数:2
dp[i][1] = dp[i][1][1] // 第一次买入 - 持有股票1: 剩余交易次数:1
dp[i][2] = dp[i][1][0] // 第一次卖出 - 未持有股票0: 剩余交易次数:1
dp[i][3] = dp[i][0][1] // 第二次买入 - 持有股票1: 剩余交易次数:0
dp[i][4] = dp[i][0][0] // 第二次卖出 - 未持有股票0: 剩余交易次数:0
初始化
dp[i][2][0] = 0
dp[i][1][1] = -prices[0]
dp[i][1][0] = 0
dp[i][0][1] = -infinity // 不可能的数字
dp[i][0][0] = 0
输出
max(dp[len-1][1][0], dp[len-1][0][0])
那么我们就可以得到一个如下对应k任意交易次数的通用框架
// k = max
k = k_max-1 to 0 // k为剩余的交易次数
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k+1][0] - prices[i])
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]) // k的实现需要依赖k+1的0(i-1)
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]) // 0的实现需要依赖k的1(i-1)
// 如果可以状态压缩,那么一定要保证当前状态更新之前所用到的状态不能更新
状态压缩后的转换方程为:
dp[k_max][0] = 0
dp[k_max][1]不存在
k = 0 to k_max-1
dp[k][0] = max(dp[k][0], dp[k][1] + prices[i])
dp[k][1] = max(dp[k][1], dp[k+1][0] - prices[i])
改写后的C++代码如下:
int maxProfit(vector<int>& prices) {
int dp[3][2] = {};
int len = prices.size();
int rst = 0;
int k_max = 2;
int special = k_max-1;
if(len >1)
{
// 初始化
for(int k=0; k<k_max; ++k)
{
dp[k][0] = -99999; //所有不可能开始的交易都置为0(未持有,剩余交易次数 < k_max)
dp[k][1] = 0; // 持有股票的收益均为0
dp[k][1] -= prices[0];
}
dp[k_max][0] = 0;
for(int i=1; i<len; ++i)
{
for(int k=0; k<k_max; ++k)
{
dp[k][0] = max(dp[k][0], dp[k][1] + prices[i]); //
dp[k][1] = max(dp[k][1], dp[k+1][0] - prices[i]); //
}
}
// k任意 未持有=0 均可能为最佳收益的候选项
for(int k=0; k<k_max; ++k)
{
rst = max(rst, dp[k][0]);
}
}
return rst;
}
- poblem1
- 一开始我将dp[k][1]置为0leetcode上报错了
- 后来将所有卖出得到的收益均置为-prices[0].测试通过
- 但是我们设置几个状态的时候却是可以跑过的
- problem2
- 还记得我们之前的问题么,为什么不逆向也能通过么
- 我也尝试了下,仍可以跑过
- 于是我加了些输出,让我们看一下这里到底是怎么回事
- ans1
- 我们用一个比较特殊的例子[1,2,3,4,5]
-------------------
第1天
剩余交易次数为:0
未持有最佳收益-99999 持有最佳收益为:0
剩余交易次数为:1
未持有最佳收益0 持有最佳收益为:-1
-------------------
-------------------
第2天
剩余交易次数为:0
前一天 未持有最佳收益-99999 持有最佳收益为:0
在今天 未持有最佳收益2 持有最佳收益为:0
/*从这里程序开始出错
由于我们初始化的时候是0,在第二天的时候
剩余交易次数为0计算的是dp[0][0] = max(dp[0][0], dp[0][1] + prices[2]);
后一项的数据是2 */
/*
dp[0][0] = dp[4]
dp[0][1] = dp[3]
在状态中dp[3] = max(dp[3], dp[2] - prices[2]) // 第二次买入
此时+-抵消
dp[2] = prices[2] - prices[1]
*/
剩余交易次数为:1
前一天 未持有最佳收益0 持有最佳收益为:-1
在今天 未持有最佳收益1 持有最佳收益为:-1
-------------------
-------------------
第3天
剩余交易次数为:0
前一天 未持有最佳收益2 持有最佳收益为:0
在今天 未持有最佳收益3 持有最佳收益为:0
剩余交易次数为:1
前一天 未持有最佳收益1 持有最佳收益为:-1
在今天 未持有最佳收益2 持有最佳收益为:-1
-------------------
-------------------
第4天
剩余交易次数为:0
前一天 未持有最佳收益3 持有最佳收益为:0
在今天 未持有最佳收益4 持有最佳收益为:0
剩余交易次数为:1
前一天 未持有最佳收益2 持有最佳收益为:-1
在今天 未持有最佳收益3 持有最佳收益为:-1
-------------------
-------------------
第5天
剩余交易次数为:0
前一天 未持有最佳收益4 持有最佳收益为:0
在今天 未持有最佳收益5 持有最佳收益为:0
剩余交易次数为:1
前一天 未持有最佳收益3 持有最佳收益为:-1
在今天 未持有最佳收益4 持有最佳收益为:-1
-------------------
- ans2 : 为什么逆向也可以输出
- [1,2,3,0,5,3,5]
- 正向与逆向其数据存储都是相同的
- 无论k的取值是多少
- 也就是说正向逆向与 压缩无关
- 我们用前面的状态来进行分析
dp[0] = 0;
dp[1] = max(dp[1], dp[0] - prices[i]); // 第一次买入
dp[2] = max(dp[2], dp[1] + prices[i]); // 第一次卖出
dp[3] = max(dp[3], dp[2] - prices[i]); // 第二次买入
dp[4] = max(dp[4], dp[3] + prices[i]); // 第二次卖出
- 每个状态都是同种分析,故我们只分析dp[4]
- dp[4] 本来需要调用的是前一天的dp[3] 但前一天的dp[3]已经被更新为今天的dp[3]
- 程序运行无错误,那么就一定是我们并没有真的用到这个dp[3] 或者昨天的dp[3]与dp[2]相同
- 我们在设计这个代码的时候有一个自己都没有发现的很巧妙的地方
- 第二次买入所获得的价值一定会比第一次买入的价值要高
- 由于我们假定可以今天买入今天卖出。那么最低也就是与买卖一次获利相同
- 因此交易次数为2次的一定大于1次
- 所以无论是正向还是逆向,我们所用到的昨天和今天的判别在max中并没有输出或者并没有什么不同
- 因为交易次数多的数一定大于交易次数小的
- 故先计算谁并不会影响输出
- 因此最后的返回其实只需要返回最后一个状态的值就可以了
- 我们通过这个可以发现其实这个状态的算法并不是最优算法,其中还是有些重复运算(重复±)
包含框架的C++代码如下
int maxProfit(vector<int>& prices) {
int dp[4][2] = {}; // dp[k_max+1][2] 二维dp数组
int len = prices.size();
int rst = 0;
int k_max = 3;
int special = k_max-1;
if(len >1)
{
// 初始化
for(int k=0; k<k_max; ++k)
{
dp[k][0] = -99999; //所有不可能开始的交易都置为0(未持有,剩余交易次数 < k_max)
dp[k][1] = 0;
dp[k][1] -= prices[0]; // 持有股票的收益均为0
}
dp[k_max-1][0] = 0;
dp[k_max][0] = 0;
cout << "-------------------" << endl;
cout << "第" << 1 << "天" << endl;
for(int k=0; k<k_max; ++k)
{
cout <<"剩余交易次数为:" << k << endl;
cout << "未持有最佳收益"<<dp[k][0] << "\t持有最佳收益为:" <<dp[k][1] << endl;
}
cout << "-------------------" << endl;
for(int i=1; i<len; ++i)
{
cout << "-------------------" << endl;
cout << "第" << i+1 << "天" << endl;
for(int k=k_max-1; k>=0; --k)
{
cout <<"剩余交易次数为:" << k << endl;
cout << "前一天\t" << "未持有最佳收益"<<dp[k][0] << "\t持有最佳收益为:" <<dp[k][1] << endl;
dp[k][0] = max(dp[k][0], dp[k][1] + prices[i]); //
dp[k][1] = max(dp[k][1], dp[k+1][0] - prices[i]); //
cout << "在今天\t" << "未持有最佳收益"<<dp[k][0] << "\t持有最佳收益为:" <<dp[k][1] << endl;
}
cout << "-------------------" << endl;
}
// k任意 未持有=0 均可能为最佳收益的候选项
for(int k=0; k<k_max; ++k)
{
rst = max(rst, dp[k][0]);
}
}
return rst;
}
如果我们在每次买卖之间加入一些其他的操作呢
- 冷冻
- 手续费
加入冷冻
int maxProfit(vector<int>& prices) {
int dp[2] = {0,0};
int len = prices.size();
int rst = 0;
if(len > 1)
{
dp[1] -= prices[0]; // 持有收益,买入
for(int i=1; i<len ;++i)
{
dp[0] = max(dp[0], dp[1]+prices[i]); // 未持有收益 = 上一天未持有 与 上一天持有卖出所获得的收益
dp[1] = max(dp[1], dp[0]-prices[i]); // 持有收益 = 上一天持有带来的收益 与 上一天未持有买入带来的收益
}
rst = dp[0];
}
return rst;
}
加入手续费
int maxProfit(vector<int>& prices) {
int dp[2] = {0,0};
int len = prices.size();
int rst = 0;
if(len > 1)
{
dp[1] -= prices[0]; // 持有收益,买入
for(int i=1; i<len ;++i)
{
dp[0] = max(dp[0], dp[1]+prices[i]); // 未持有收益 = 上一天未持有 与 上一天持有卖出所获得的收益
dp[1] = max(dp[1], dp[0]-prices[i]); // 持有收益 = 上一天持有带来的收益 与 上一天未持有买入带来的收益
}
rst = dp[0];
}
return rst;
}
二维数组:dp[len][3]
- dp[i][j] 0<i<len-1 0<j<2
- 在索引(0,i)内数组个数为j+1的子数组的最大和A
// 状态转换方程
dp[i][j] = max(dp[i-1][j], dp[i-gill][j-1] + nums[i]);
// 初始化
s = 0 to gill //确保不会越界,并正确输入
dp[s][0] = nums[s];
dp[s][1] = 0;
dp[s][2] = 0;
//输出
dp[len-1][2]
// 状态压缩
用滚动数组可以实现状态压缩
dp[k-1][3]
//重构最优解