基于 【动态规划3】–背包问题/贪婪问题的DP 解题。
进一步拓展其它 动态规划问题。
以及 区分几个性质的答疑部分。
状态
首先状态 dp 一定能自己想出来。
dp[i][j] 表示 s 的前 i 个是否能被 p 的前 j 个匹配
转移方程
怎么想转移方程?首先想的时候从已经求出了 dp[i-1][j-1] 入手,再加上已知 s[i]、p[j],要想的问题就是怎么去求 dp[i][j]。
已知 dp[i-1][j-1] 意思就是前面子串都匹配上了,不知道新的一位的情况。
那就分情况考虑,所以对于新的一位 p[j] s[i] 的值不同,要分情况讨论:
第一个难想出来的点:怎么区分 ∗
的两种讨论情况
首先给了 *
,明白 *
的含义是 匹配零个或多个前面的那一个元素,所以要考虑他前面的元素 p[j-1]。*
跟着他前一个字符走,前一个能匹配上 s[i],*
才能有用,前一个都不能匹配上 s[i],*
也无能为力,只能让前一个字符消失,也就是匹配 0 次前一个字符。
所以按照 p[j-1] 和 s[i] 是否相等,我们分为两种情况:
3.1 p[j-1] != s[i] : dp[i][j] = dp[i][j-2]
*
往前看两个,发现前面 s[i] 的 ab 对 p[j-2] 的 ab 能匹配,虽然后面是 c*,但是可以看做匹配 0 次 c,相当于直接去掉 c *,所以也是 True。注意 (ab, abc**
) 是 False。3.2 p[j-1] == s[i] or p[j-1] == “.”:
*
),或者 ( ##b , ### . * ) 只看 ### 后面一定是能够匹配上的。第二个难想出来的点:怎么判断前面是否匹配
注意:此处为与或非判断;不是if 判断了。;因为从后向前。
dp[i][j] = dp[i-1][j] // 多个字符匹配的情况
or dp[i][j] = dp[i][j-1] // 单个字符匹配的情况
or dp[i][j] = dp[i][j-2] // 没有匹配的情况
看 ### 匹不匹配,不是直接只看 ### 匹不匹配,要综合后面的 b b* 来分析
这三种情况是 or的关系,满足任意一种都可以匹配上,同时是最难以理解的地方:
dp[i-1][j] 就是看 s 里 b 多不多, ### 和 ###b * 是否匹配,一旦匹配,s 后面再添个 b 也不影响,因为有 * 在,也就是 ###b 和 ###b *也会匹配。
dp[i][j-1] 就是去掉 * 的那部分,###b 和 ###b 是否匹配,比如 qqb qqb
dp[i][j-2] 就是 去掉多余的 b ,p 本身之前的能否匹配,###b 和 ### 是否匹配,比如 qqb qqbb 之前的 qqb qqb 就可以匹配,那多了的 b * 也无所谓,因为 b * 可以是匹配 0 次 b,相当于 b * 可以直接去掉了。
三种满足一种就能匹配上。
为什么没有 dp[i-1][j-2] 的情况? 就是 ### 和 ### 是否匹配?因为这种情况已经是 dp[i][j-1] 的子问题。也就是 s[i]==p[j-1],则 dp[i-1][j-2]=dp[i][j-1]。
最后来个归纳:
关键点:这里只设置
True
.
- 因为DP自底向上,所以要从后向前 分情况讨论
*
. 且 False初始化了,不用想不等于。- 匹配如何匹配
*
的多个前面字符?满足 三种情况即可。【多个,单个,无。】注意:此处为与或非判断;不是if 判断了。
1. 常规解法:
bool isMatch(string s, string p) {
int m=s.size(), n=p.size();
vector<vector<bool>> dp(m+1, vector<bool>(n+1, false));
// base
dp[0][0] = true;
// important dp[0][...]
// 重点新增 dp[0]的初始化!!! 注意!!!
for(int i = 1;i<=n;i++){
if(p[i-1]=='*' && i>=2){
dp[0][i] = dp[0][i-2];
}
}
// start dp
for(int i = 1;i<=m;i++){
for(int j = 1; j<=n ;j++){
if(s[i-1] == p[j-1] || p[j-1]=='.'){//如果是任意元素 或者是对于元素匹配
dp[i][j] = dp[i-1][j-1];
}else if(p[j-1]=='*'){
//如果前一个元素不匹配 且不为任意元素
if(p[j-2] != s[i-1] && p[j-2]!='.') dp[i][j] = dp[i][j-2];
else{// if(p[j-2]==s[i-1]||p[j-2]=='.')
dp[i][j] = (dp[i][j-1]||dp[i-1][j]||dp[i][j-2]);
/*
dp[i][j] = dp[i-1][j] // 多个字符匹配的情况
or dp[i][j] = dp[i][j-1] // 单个字符匹配的情况
or dp[i][j] = dp[i][j-2] // 没有匹配的情况
*/
}
}
}
}
return dp[m][n];
}
2. 无base 解法:
class Solution {
public:
bool isMatch(string s, string p) {
s=" "+s;//防止该案例:""\n"c*"
p=" "+p;
int m=s.size(),n=p.size();
bool dp[m+1][n+1];
memset(dp,false,(m+1)*(n+1));
dp[0][0]=true;
for(int i=1;i<=m;i++){
for(int j=1;j<=n;j++){
if(s[i-1]==p[j-1] || p[j-1]=='.'){
dp[i][j]=dp[i-1][j-1];
}
else if(p[j-1]=='*'){
if(s[i-1]!=p[j-2] && p[j-2]!='.')
dp[i][j]=dp[i][j-2];
else{
dp[i][j]=dp[i][j-1] || dp[i][j-2] || dp[i-1][j];
}
}
}
}
return dp[m][n];
}
};
我的题解,打家劫舍1
* @Description : 房屋偷盗
* 198. 打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
*/
#include
#include
#include
#include
#include
#include
using namespace std;
class Solution {
public:
/**
* @Description: dp[i] 表示从i开始 最大能偷盗多少钱
* @param {*}
* @return {*}
* @notes:
*/
int rob(vector<int>& nums) {
int n = nums.size();
//base n+2 最后是0
vector<int> dp(n+2, 0);
// dp start
for(int i=n-1; i>=0 ;i--){
dp[i] = max(dp[i+1], // 不取
nums[i]+dp[i+2]); // 取
}
return dp[0];
}
/**
* @Description: 状态压缩
* @param {*}
* @return {*}
* @notes:
*/
int rob(vector<int>& nums) {
int n = nums.size();
//base n+2 最后是0
int dp_2 = 0, dp_1 = 0;
int dp = 0;
// dp start
for(int i=n-1; i>=0 ;i--){
dp = max(dp_1, // 不取
nums[i]+dp_2); // 取
dp_2 = dp_1;
dp_1 = dp;
}
return dp;
}
};
我的题解,打家劫舍2
* @Description : 213. 打家劫舍 II
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,能够偷窃到的最高金额。
示例 1:
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
示例 2:
输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 3:
输入:nums = [0]
输出:0
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 1000
*/
#include
#include
#include
#include
#include
#include
using namespace std;
class Solution {
public:
int RobRange(vector<int>& nums, int start, int end){
int n = nums.size();
//base n+2 最后是0
int dp_2 = 0, dp_1 = 0;
int dp = 0;
// dp start
for(int i=end; i>=start ;i--){
dp = max(dp_1, // 不取
nums[i]+dp_2); // 取
dp_2 = dp_1;
dp_1 = dp;
}
return dp;
}
int rob(vector<int>& nums) {
// 分析单调栈模板的——通过 分析选择情况二三。
int n = nums.size();
if(n == 1) return nums[0];
return max(RobRange(nums, 0, n-2),
RobRange(nums, 1, n-1));
}
};
我的题解,打家劫舍3
* @Description : 房屋抢劫III
* 337. 打家劫舍 III
在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为“根”。 除了“根”之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。
计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。
示例 1:
输入: [3,2,3,null,3,null,1]
3
/ \
2 3
\ \
3 1
输出: 7
解释: 小偷一晚能够盗取的最高金额 = 3 + 3 + 1 = 7.
示例 2:
输入: [3,4,5,1,3,null,1]
3
/ \
4 5
/ \ \
1 3 1
输出: 9
解释: 小偷一晚能够盗取的最高金额 = 4 + 5 = 9.
*/
#include
#include
#include
#include
#include
#include
#include
using namespace std;
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
class Solution {
public:
/**
* @Description: 继续使用实际定义——找状态 + 选择 == 偷or不偷
* @param {*}
* @return {*}
* @notes: rob 返回当前node下 偷取的最大数值
*/
int rob(TreeNode* root) {
unordered_map<TreeNode*, int> memo;
return RobRecall(root, memo);
}
int RobRecall(TreeNode* root, unordered_map<TreeNode* , int> &memo){
if(!root) return 0;
if(memo.find(root) != memo.end()) return memo[root];
// dp start
int res = 0;
int get = 0, not_get = 0;
// 取
get = root->val +
(root->left==nullptr?0:RobRecall(root->left->left, memo)+RobRecall(root->left->right, memo)) +
(root->right==nullptr?0:RobRecall(root->right->left, memo)+RobRecall(root->right->right, memo));
// 不取
not_get = RobRecall(root->left, memo) + RobRecall(root->right, memo);
res = max(get, not_get);
memo[root] = res;
return res;
}
};
上一篇文章 一行代码就能解决的智力题 中讨论到一个有趣的「石头游戏」,通过题目的限制条件,这个游戏是先手必胜的。
但是智力题终究是智力题,真正的算法问题肯定不会是投机取巧能搞定的。所以,本文就借石头游戏来讲讲「假设两个人都足够聪明,最后谁会获胜」这一类问题该如何用动态规划算法解决。
博弈类问题的套路都差不多,下文举例讲解,其核心思路是在二维 dp 的基础上使用元组分别存储两个人的博弈结果。掌握了这个技巧以后,别人再问你什么俩海盗分宝石,俩人拿硬币的问题,你就告诉别人:我懒得想,直接给你写个算法算一下得了。
我们「石头游戏」改的更具有一般性:
你和你的朋友面前有一排石头堆,用一个数组 piles 表示,piles[i] 表示第 i 堆石子有多少个。你们轮流拿石头,一次拿一堆,但是只能拿走最左边或者最右边的石头堆。所有石头被拿完后,谁拥有的石头多,谁获胜。
石头的堆数可以是任意正整数,石头的总数也可以是任意正整数,这样就能打破先手必胜的局面了。 比如有三堆石头 piles = [1,100,3],先手不管拿 1 还是 3,能够决定胜负的 100 都会被后手拿走,后手会获胜。
假设两人都很聪明,请你设计一个算法,返回先手和后手的最后得分(石头总数)之差。比如上面那个例子,先手能获得 4 分,后手会获得 100 分,你的算法应该返回 -96。
这样推广之后,这个问题算是一道 Hard 的动态规划问题了。博弈问题的难点在于,两个人要轮流进行选择,而且都贼精明,应该如何编程表示这个过程呢?
定义 dp 数组的含义是很有技术含量的,同一问题可能有多种定义方法,不同的定义会引出不同的状态转移方程,不过只要逻辑没有问题,最终都能得到相同的答案。
我建议不要迷恋那些看起来很牛逼,代码很短小的奇技淫巧,最好是稳一点,采取可解释性最好,最容易推广的设计思路。本文就给出一种博弈问题的通用设计框架。
介绍 dp 数组的含义之前,我们先看一下 dp 数组最终的样子:
下文讲解时,认为元组是包含 first 和 second 属性的一个类,而且为了节省篇幅,将这两个属性简写为 fir 和 sec。比如按上图的数据,我们说 dp[1][3].fir = 10,dp[0][1].sec = 3。
先回答几个读者可能提出的问题:
这个二维 dp table 中存储的是元组,怎么编程表示呢?这个 dp table 有一半根本没用上,怎么优化?很简单,都不要管,先把解题的思路想明白了再谈也不迟。
以下是对 dp 数组含义的解释:
我们想求的答案是先手和后手最终分数之差,按照这个定义也就是 dp[0][n−1].fir−dp[0][n−1].sec
.
写状态转移方程很简单,首先要找到所有「状态」和每个状态可以做的「选择」,然后择优。
根据前面对 dp 数组的定义,状态显然有三个:开始的索引 i,结束的索引 j,当前轮到的人。
dp[i][j][fir or sec]
其中:
0 <= i < piles.length
i <= j < piles.length
对于这个问题的每个状态,可以做的选择有两个:选择最左边的那堆石头,或者选择最右边的那堆石头。 我们可以这样穷举所有状态:
上面的伪码是动态规划的一个大致的框架,股票系列问题中也有类似的伪码。这道题的难点在于,两人是交替进行选择的,也就是说先手的选择会对后手有影响,这怎么表达出来呢?
根据我们对 dp 数组的定义,很容易解决这个难点,写出状态转移方程:
好家伙!上面的 DP状态转移方程 绝了!DP数组设置的真好!
根据 dp 数组的定义,我们也可以找出 base case,也就是最简单的情况:
这里需要注意一点,我们发现 base case 是斜着的,而且我们推算 dp[i][j] 时需要用到 dp[i+1][j] 和 dp[i][j-1]:
所以说算法不能简单的一行一行遍历 dp 数组,而要斜着遍历数组:
说实话,斜着遍历二维数组说起来容易,你还真不一定能想出来怎么实现,不信你思考一下?这么巧妙的状态转移方程都列出来了,要是不会写代码实现,那真的很尴尬了。
如何实现这个 fir 和 sec 元组呢,你可以用 python,自带元组类型;或者使用 C++ 的 pair 容器;或者用一个三维数组 dp[n][n][2],最后一个维度就相当于元组;或者我们自己写一个 Pair 类:
class Pair {
int fir, sec;
Pair(int fir, int sec) {
this.fir = fir;
this.sec = sec;
}
}
然后直接把我们的状态转移方程翻译成代码即可,可以注意一下斜着遍历数组的技巧:
class Solution {
public:
int stoneGameVII(vector<int>& stones) {
int n = stones.size();
if(n == 0) return 0;
// 表示从i到j 先后手(fir, sec)选择获得的最大分值
vector<vector<pair<int,int>>> dp(n, vector<pair<int,int>>(n, make_pair(0,0)));
// base
for(int i = 0;i<n;i++){
dp[i][i].first = stones[i];
}
//dp start 目标:倾斜 dp[0][n-1]
for(int num = n-2;num>=0;num--){
for(int i = 0;i<n;i++){
int j = n-num+i-1;
// zhuanyi
// first
if(j>=0 && j < n){
int left = stones[i]+dp[i+1][j].second, right = stones[j]+dp[i][j-1].second;
dp[i][j].first = max(left, right);
if(left>=right){
dp[i][j].second = dp[i+1][j].first;
}else{
dp[i][j].second = dp[i][j-1].first;
}
}
}
}
// 返回差值
return dp[0][n-1].first-dp[0][n-1].second;
}
};
动态规划解法,如果没有状态转移方程指导,绝对是一头雾水,但是根据前面的详细解释,读者应该可以清晰理解这一大段代码的含义。
【可以状态压缩, 没必要了 难理解。】而且,注意到计算 dp[i][j] 只依赖其左边和下边的元素,所以说肯定有优化空间,转换成一维 dp,想象一下把二维平面压扁,也就是投影到一维。但是,一维 dp 比较复杂,可解释性很差,大家就不必浪费这个时间去理解了。
本文给出了解决博弈问题的动态规划解法。博弈问题的前提一般都是在两个聪明人之间进行,编程描述这种游戏的一般方法是二维 dp 数组,数组中通过元组分别表示两人的最优决策。 【 关键在于 dp数组
的定义, 使用了元组 模拟了两个人的先后手;而且角色之间的转换 可以重用,典型动规。】
之所以这样设计,是因为先手在做出选择之后,就成了后手,后手在对方做完选择后,就变成了先手。这种角色转换使得我们可以重用之前的结果,典型的动态规划标志。
读到这里的朋友应该能理解算法解决博弈问题的套路了。学习算法,一定要注重算法的模板框架,而不是一些看起来牛逼的思路,也不要奢求上来就写一个最优的解法。不要舍不得多用空间,不要过早尝试优化,不要惧怕多维数组。dp 数组就是存储信息避免重复计算的,随便用,直到咱满意为止。
关键是理解这个 博弈过程——每个人在当前都想达到最好的 值!
即:先手必定得到目前最大的得分。
解题思路:
我的题解
/**
* @Description: 去除一个 剩下所有为获得的分值。
* @param {*}
* @return {*}
* @notes:
*/
int stoneGameVII(vector<int>& stones) {
int n = stones.size();
if(n == 0) return 0;
vector<vector<int>> dp(n, vector<int>(n,0));
vector<int> sums(n+1, 0); // 之前的所有数之和 包含自身
// 涉及到sums[-1] 所以前移动一位
sums[0] = 0;
for(int i = 1;i < n+1;++i)
sums[i] = sums[i-1] + stones[i-1];
for(int i = dp.size() - 2;i >= 0;--i){
for(int j = i + 1;j < dp[i].size();++j){
// 博弈 —— 每次选取当前能要的最大的。每个人! i……j
dp[i][j] = max(sums[j+1] - sums[i+1] - dp[i + 1][j], sums[j] - sums[i] - dp[i][j - 1]);
}
}
return dp[0][n-1];
}
LeetCode 上有 6 道关于股票买卖的问题,难度较大。本文给大家分析出一套框架,只要通过简单的变形,就能解决所有问题。
首先申明,本文介绍的只是一个种可行方案,不是最优的。这几道股票买卖题目测试数据的规模非常大,所以有几道题目按照本文介绍的方法进行提交是无法通过的,会在最后一个测试用例得到超时或超内存的错误。
虽然不能通过,我还是写了这篇文章,说明一下用意:
学习的过程分为两个阶段,先是「从 0 到 1」,然后「从 1 到 N」。你看懂一道题,只能算「从 0 到 1」,如果不能复现这种设计思路解决一类问题,那么过两天你就忘了。但是如果你能掌握一个框架,就能无限复制,举一反三,解决一大类问题。显然后者的价值更大。
读者应该能体会我的文章风格,不会单独拿出某个奇技淫巧来讲,而是尽可能成体系,就是力求给读者带来「从 1 到 N」的价值。所以我认为,既然最优解法不好理解,那么不妨先了解一套简单可行的次优解法。
本文给出的动态规划思路复杂度是 O(N^2),经过简单变形就能处理所有问题,层层递进,可读性好。最优解法复杂度 O(N),涉及状态机,比较难以理解,等我整理一套简单可复制的框架出来,再发篇文章详解最优解法。【下一节会讲】
当大部分人还束手无策时,你已经快速写出一个次优解,这也能体现差距呀。相信本文能给你带来价值。
说了那么多废话,进入正题。
动态规划详解 说过,计算机解决问题的方法就是穷举。遇到一个问题,如果想不到什么奇技淫巧,那么首先请读者自问:如何穷举这个问题的所有可能性?
这个问题的穷举很简单,我们可以这样写:
所有可能 = { 第 x 天买,第 y 天卖 }
其中 0 <= x < len(prices),
x < y < len(prices)
result = max(所有可能)
我估计有的读者会开始质疑应该是开区间还是闭区间了。但是框架思维告诫我们,请暂时忽略这种细节问题,保持思路推进,等会出错的话再回来深究。现在把上述思路转化成代码:
实际上,这个解法就是可行的,能够得到正确答案。但是我们分析一下这个算法在干嘛,就能发现一些冗余计算。
如上图,可以看到大量的重复操作。我们相当于固定了买入时间 buy,然后将 buy 后面的每一天作为 sell 进行穷举,只为寻找 prices[sell] 最大的那天,因为这样 prices[sell] - prices[buy] 的差价才会最大。
如果反过来想,固定卖出时间 sell,向前穷举买入时间 buy,寻找 prices[buy] 最小的那天,是不是也能达到相同的效果?是的,而且这种思路可以减少一个 for 循环。
为什么可以减少一个 for 循环呢?我举个例子你就很容易理解了。
假设你有一堆数字,你知道其中最大的数,现在从中取走一个数,你还知道最大的那个数是多少吗?不一定,如果拿走的这个数不是那个最大数,那么最大数不变;如果拿走的恰好是那个最大的数,你就得重新遍历这堆数字以寻找之前第二大的那个数,作为新的最大数。这就是我们的原始算法,每向后移动一位,就要重新遍历寻找最大值。
但是,假设你知道一堆数字中最小的那个,再添加一个新的数字,你现在是否知道最小的数字是那个?知道,只要比较一下新数和当前最小的数字,就能得到新的最小数。这就是优化算法的情况,所以可以消除嵌套循环的计算冗余。
关键不在于最大值还是最小值,而是数字的添加和减少。添加新数时,可以根据已有最值,推导出新的最值;而减少数字时,不一定能直接推出新的最值,不得不重新遍历。
很多人认为这道题不是动态规划,但是我认为最值的更新就是旧状态向新状态的转移,所以这个问题还是含有动态规划的技巧的。不要觉得此题简单,这里完成了最困难的一步:穷举。后面所有的题目都可以基于此框架扩展出来。
一步一步地,学会了 从穷举的O(N^2) =》 O(N)的转变。
这道题允许多次交易,看起来比刚才的问题复杂了很多,怎么办?没有思路第一步,想想如何穷举所有可能结果。
来尝试一下吧,如果用 for 循环来穷举,会出现什么情况?
遇到这种无穷 for 循环的情况,就是使用递归的强烈暗示。我们上一题的框架只能解决一次买卖的最大收益,现在的问题是,进行一次股票卖出后,下一次应该在什么时候交易呢?这个问题和原问题具有相同结构,规模减小,典型的递归场景。只要给原框架稍加改动即可。
这道题已经做出来了,优化两步:先根据上一题消除一层循环,然后加个备忘录。优化就属于走流程,没啥可说的。之后问题的解法,都是在此代码上的简单改造。
/**
* @Description: 方法二: 基于方法一 降低时间复杂度为线性。 || 方法一超出时间限制
* @param {*}
* @return {*}
* @notes:
*/
int maxProfit(vector<int>& prices) {
vector<int> memo(prices.size(), -1); // 记录开始交易后 重叠记录的最大收益
return dp(prices, 0, memo);
}
int dp(vector<int>& prices, int start, vector<int>& memo){ // 开始交易的时间
// 边界
int n = prices.size();
if(start >= n) return 0;
// memo
if(memo[start] != -1) return memo[start];
// 递归 开始
int maxPro = 0;
// 优化:
int curMin = prices[start];
for(int i = start+1; i<n; i++){ // sell 到n
curMin = min(curMin, prices[i]);
maxPro = max(maxPro, dp(prices,i+1, memo)+prices[i]-curMin);
}
// for(int i = start;i
// for(int j = i+1; j
// maxPro = max(maxPro, dp(prices, j+1, memo)+prices[j]-prices[i]);
// }
// }
// 记录并返回
memo[start] = maxPro;
return maxPro;
}
但是递归 还是会超出时间限制!
所以使用下面的 优化思维过程——最终是 贪心算法胜出。
怎么看出重叠子问题,前文 动态规划之正则表达式 有介绍,显然一个子数组切片可以通过多条递归路径得到,所以子问题一定有重叠。
但是,这样提交会得到一个内存超过限制的错误。原来有一个测试用例特别长,我们的 memo 备忘录太大了。怎么办呢,是否可以想办法减小备忘录占用的空间?答案是不可以。
动态规划详解 中对斐波那契数列的优化,就用到一个技巧直接省略了备忘录和 DP table,用 O(1) 的空间完成了计算。但是这里不适用,首先我们把斐波那契的框架抽出来:
int fib(int n) {
fib(n - 1);
fib(n - 2);
}
可以看到原问题 fib(n) 只依赖子问题 fib(n - 1) 和 fib(n - 2),所以我们的备忘录也好,DP table 也好,一次只用记录两个子问题的答案即可。但是抽象出当前算法的框架:
def dp(start):
for sell in range(start + 1, len(prices)):
dp(sell)
显然,如果求解原问题 dp(0),要依赖子问题 dp(1), dp(2) … dp(len(prices) - 1),反正数量不是个定值,所以备忘录必须开那么大,否则装不下这些依赖子问题呀!说明这就是动态规划的极限了,真的不能再优化了。
这个问题的最优解法是 「贪心算法」。贪心算法是基于动态规划之上的一种特殊方法,对于某些特定问题可以比动态规划更高效。 这道题用贪心很简单,就贴一下代码吧,读者应该可以很容易理解。
核心思想就是:既然可以预知未来,那么能赚一点就赚一点。
图片来自 www.leetcode.com
3、Best Time to Buy and Sell Stock III/IV (Hard)
第三题和第四题类似,就是限定了你的最大交易次数,只要解决第四题就行了,看题目:
图片
直接套上面的框架,把这个约束 k 加进去即可:
时间复杂度 O(kN^2),会在最后一个测试用例超时,不过好歹做出来一个可行答案,起码有 90 分吧。
4、Best Time to Buy and Sell Stock with Cooldown (Medium)
图片
题目意思就是,你卖出之后,你的资金要冻结一天,不能第二天立即买入,至少要隔一天才能选择买入。套进框架,只要改一下子问题的参数就行了。
复杂度 O(N^2),也会卡在最后的大规模数据,给 90 分吧。被扣了 10 分不重要,重要的是得到 90 分只花了一秒钟。
5、Best Time to Buy and Sell Stock with Transaction Fee (Medium)
图片
每次卖出需要手续费,套进框架,把手续费从利润中减掉即可。
本文终。总结一下,我们通过最简单的两个问题,形成了一套算法模板,快速解决了剩下的困难问题。通过备忘录技巧,保持时间复杂度在 O(N^2) 级别,虽不是最优的,但也是可行的。
【总结】股票买卖的状态机 编程过程的重点:
- 明白
k
表示当前状态下所能达到的 最大收益。0
表示不交易最大收益就是0!- 每种题目的
base case
要会手动变形、写出。- 次重点: 区分好
状态压缩
时,变量 重用的错误!!! 顺序区分好,别被动更新为错误的数值了!
上一节 LeetCode 股票问题的一种通用解法 用递归的方法实现了一套简单易懂的可行解,但是时间复杂度略高,不能通过全部测试用例。
这篇文章用「状态机」的技巧给出最优解,可以全部提交通过。不要觉得这个名词高大上,文学词汇而已,实际上就是 DP table,等会儿一讲就明白了。
能看懂吧?会做了吧?不可能的,你看不懂,这才正常。就算你勉强看懂了,下一个问题你还是做不出来。那为什么别人能写出这么诡异却又高效的解法呢?因为这类问题是有框架的,但是人家不会告诉你的,因为一旦告诉你,你十分钟就学会了,该算法题就不再神秘,变得不堪一击了。
本文就来告诉你处理这类问题的框架,拒绝奇技淫巧,稳扎稳打,以不变应万变。
这 6 道题目是有共性的,本文通过对第四道题的分析,逐步解决所有问题。因为第四题是一个最泛化的形式,其他的问题都是这个形式的简化。看下题目:
第一题是只进行一次交易,相当于 k = 1;第二题是不限交易次数,相当于 k = +infinity(正无穷);第三题是只进行 2 次交易,相当于 k = 2;剩下两道也是不限交易次数,但是加了交易「冷冻期」和「手续费」的额外条件,其实就是第二题的变种,都很容易处理。
如果你还不熟悉题目,可以去 LeetCode 或者上篇文章 一种通用思路 查看这些题目的内容,本文为了节省篇幅,就不列举这些题目的具体内容了。下面言归正传,开始详解。
首先,还是一样的思路:如何穷举?这里的穷举思路和上篇文章递归的思想不太一样。
递归其实是符合我们思考的逻辑的,一步步推进,遇到无法解决的就丢给递归,一不小心就做出来了,可读性还很好。缺点就是一旦出错,你也不容易找到错误出现的原因。比如上篇文章的递归解法,肯定还有计算冗余,但确实不容易找到。
而这里,我们不用递归思想进行穷举,而是利用「状态」进行穷举。
看看总共有几种「状态」,再找出每个「状态」对应的「选择」。我们要穷举所有「状态」,穷举的目的是根据对应的「选择」更新状态。看图,就是这个意思。
具体到当前问题,每天都有三种「选择」:买入、卖出、无操作,我们用 buy, sell, rest 表示这三种选择。
但问题是,并不是每天都可以任意选择这三种选择的,因为 sell 必须在 buy 之后,buy 必须在 sell 之后(第一次除外)。那么 rest 操作还应该分两种状态,一种是 buy 之后的 rest(持有了股票),一种是 sell 之后的 rest(没有持有股票)。而且别忘了,我们还有交易次数 k 的限制,就是说你 buy 还只能在 k > 0 的前提下操作。
很复杂对吧,不要怕,我们现在的目的只是穷举,你有再多的状态,老夫要做的就是一把“梭哈”全部列举出来。这个问题的「状态」有三个,第一个是天数,第二个是当天允许交易的最大次数,第三个是当前的持有状态(即之前说的 rest 的状态,我们不妨用 1 表示持有,0 表示没有持有)。
我们用一个三维数组 dp 就可以装下这几种状态的全部组合,用 for 循环就能完成穷举:
而且我们可以用自然语言描述出每一个状态的含义,比如说 dp[3][2][1] 的含义就是:今天是第三天,我现在手上持有着股票,至今最多进行 2 次交易。再比如 dp[2][3][0] 的含义:今天是第二天,我现在手上没有持有股票,至今最多进行 3 次交易。很容易理解,对吧?
我们想求的最终答案是 dp[n - 1][K][0],即最后一天,最多允许 K 次交易,所能获取的最大利润。读者可能问为什么不是 dp[n - 1][K][1]?因为 [1] 代表手上还持有股票,[0] 表示手上的股票已经卖出去了,很显然后者得到的利润一定大于前者。
记住如何解释「状态」,一旦你觉得哪里不好理解,把它翻译成自然语言就容易理解了。
非常像 编译原理的阐述。。。?
现在,我们完成了「状态」的穷举,我们开始思考每种「状态」有哪些「选择」,应该如何更新「状态」。
因为我们的选择是 buy, sell, rest,而这些选择是和「持有状态」相关的,所以只看「持有状态」,可以画个状态转移图。
状态机与状态转移方程图 很重要。
通过这个图可以很清楚地看到,每种状态(0 和 1)是如何转移而来的。根据这个图,我们来写一下状态转移方程:
这个解释应该很清楚了,如果 buy,就要从利润中减去 prices[i],如果 sell,就要给利润增加 prices[i]。今天的最大利润就是这两种可能选择中较大的那个。而且注意 k 的限制,我们在选择 buy 的时候,把最大交易数 k 减小了 1,很好理解吧,当然你也可以在 sell 的时候减 1,一样的。
现在,我们已经完成了动态规划中最困难的一步:状态转移方程。如果之前的内容你都可以理解,那么你已经可以秒杀所有问题了,只要套这个框架就行了。不过还差最后一点点,就是定义 base case,即最简单的情况。
根据状态转移方程,可以写出来以下 base case。
注意理解: 返回的一定是k=2状态,因为 每个状态机都代表—— 当前状态下递推(递归似的)的既得收益!
目标:dp[n-1][k][0]
;即:交易n天 最大允许k次交易,不持有股票的最后一天的最大利润是多少。
且 上下的dp[-1][k][1]=-inf
但是dp[0][k][1]=-prices[0]
读者可能会问,这个数组索引是 -1 怎么编程表示出来呢,负无穷怎么表示呢?这都是细节问题,有很多方法实现。现在整体框架已经完成,下面开始具体化。
直接套状态转移方程,根据 base case,可以做一些化简:
直接翻译成代码:
显然 i = 0 时 dp[i-1] 是不合法的。这是因为我们没有对 i 的 base case 进行处理。那就简单粗暴地处理一下:
第一题就解决了,但是这样处理 base case 很麻烦,而且注意一下状态转移方程,新状态只和相邻的一个状态有关,其实不用整个 dp 数组,只需要两个变量储存所需的状态就足够了,这样可以把空间复杂度降到 O(1):
两种方式都是一样的,不过这种编程方法简洁很多。但是如果没有前面状态转移方程的引导,是肯定看不懂的。后续的题目,我主要写这种空间复杂度 O(1) 的解法。
如果 k 为正无穷,那么就可以认为 k 和 k - 1 是一样的。可以这样改写框架:
区别于k=1;因为此处 dp[i][1] = max(xxx, dp[i-1][0]-prices[i]);
多了 dp[i-1][0], 没问题。
直接翻译成代码即可:
每次 sell 之后要等一天才能继续交易。只要把这个特点融入上一题的状态转移方程即可:
注意这里冷静期直接 上一题 basecase 修改一个状态准仪过程即可。
直接翻译成代码即可:
/**
* @Description: 状态机+压缩空间: 带有冷静期 1天。
* @param {*}
* @return {*}
* @notes: 关键:卖出后 冷静期1天才能买。
*/
int maxProfit(vector<int>& prices) {
if(prices.size() == 0) return 0;
int dp_i_0=0, dp_i_1 = -prices[0];
int dp_i2_0 = 0;
for(int i = 0; i<prices.size(); i++){
// 记录上上个状态
int temp1 = dp_i_0;
dp_i_0 = max(dp_i_0, dp_i_1+prices[i]); // 上个状态更新了,下面有用了!
dp_i_1 = max(dp_i_1, dp_i2_0-prices[i]); // dp[i-2][0]
dp_i2_0 = temp1;
}
return dp_i_0;
}
每次交易要支付手续费,只要把手续费从利润中减去即可:
买入的第二个 状态转移方程 减去fee 即可。
注意 买入和卖出的减去fee 解法不同!【关键】要遵从实际情况,方可 直接作对啦!(因为 basecase就不一样的 初始化方法!)
直接翻译成代码即可:
我的题解!注意 买入和卖出的减去fee 解法不同!【关键】要遵从实际情况,方可 直接作对啦!(因为 basecase就不一样的 初始化方法!)
/**
* @Description: 状态机:带有手续费的 股票交易的 无限次交易。
* @param {int} fee
* @return {*}
* @notes: 关键:在买入或卖出的时候 支付手续费。这里在 买入的时候k-1 时同时减去 手续费用。
* 相应的 basecase 也要减去 fee!!!
*/
int maxProfit(vector<int>& prices, int fee) {
if(prices.size() == 0) return 0;
int dp_i_0=0, dp_i_1 = -prices[0]-fee;
// int dp_i2_0 = 0;
for(int i = 0; i<prices.size(); i++){
// 记录上上个状态
int temp1 = dp_i_0;
dp_i_0 = max(dp_i_0, dp_i_1+prices[i]); // 上个状态更新了,下面有用了!
dp_i_1 = max(dp_i_1, temp1-prices[i]-fee); // dp[i-2][0]
// dp_i2_0 = temp1;
}
return dp_i_0;
}
/**
* @Description: 状态机:带有手续费的 股票交易的 无限次交易。
* @param {int} fee
* @return {*}
* @notes: 关键:在买入或卖出的时候 支付手续费。买入的时候k-1 时同时减去 手续费用。
* 相应的 basecase 也要减去 fee!!!
*
* 这里在卖出的时候 减去手续费,所以买入的 basecase 不用减去fee!!!
*/
int maxProfit(vector<int>& prices, int fee) {
if(prices.size() == 0) return 0;
int dp_i_0=0, dp_i_1 = -prices[0]; //【不同
// int dp_i2_0 = 0;
for(int i = 0; i<prices.size(); i++){
// 记录上上个状态
int temp1 = dp_i_0;
dp_i_0 = max(dp_i_0, dp_i_1+prices[i]-fee); //【不同 // 上个状态更新了,下面有用了!
dp_i_1 = max(dp_i_1, temp1-prices[i]); //【不同 // dp[i-2][0]
// dp_i2_0 = temp1;
}
return dp_i_0;
}
k = 2 和前面题目的情况稍微不同,因为上面的情况都和 k 的关系不太大**。要么 k 是正无穷,状态转移和 k 没关系了;要么 k = 1,跟 k = 0 这个 base case 挨得近,最后也被消掉了。**
k=1;k=init+ 都被消去了k。
2<=k
这道题 k = 2 和后面要讲的 k 是任意正整数的情况中,对 k 的处理就凸显出来了。我们直接写代码,边写边分析原因。
按照之前的代码,我们可能想当然这样写代码(错误的):
为什么错误?我这不是照着状态转移方程写的吗?
还记得前面总结的「穷举框架」吗?就在强调必须穷举所有状态。其实我们之前的解法,都在穷举所有状态,只是之前的题目中 k 都被化简掉了,所以没有对 k 的穷举。比如说第一题,k = 1:
这道题由于没有消掉 k 的影响,所以必须要用 for 循环对 k 进行穷举才是正确的:
/**
* @Description: k=2 状态机
* @param {*}
* @return {*} 注意理解: 返回的一定是k=2状态,因为 每个状态机都代表——
* 当前状态下递推(递归似的)的既得收益!
* @notes: 关键:状态机穷举所有状态 + 使用所有选择 更新。
*/
int maxProfit(vector<int> &prices)
{
int n = prices.size();
int max_k = 2;
// dp 数组
vector<vector<vector<int>>> dp(n, vector<vector<int>>(max_k + 1, vector<int>(2, 0)));
// base
// dp start
for (int i = 0; i < n; i++)
{
if (i - 1 == -1)
{
dp[i][1][0] = 0;
dp[i][1][1] = -prices[i];
dp[i][2][0] = 0;
dp[i][2][1] = -prices[i];
continue; // cuo
}
for (int k = max_k; k >= 1; k--) // 正反都可以,因为 主要是下面状态压缩怕正反 修改数值。
{ // cuo wu
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]);
}
}
// return
return dp[n - 1][max_k][0];
}
如果你不理解,可以返回第一点「穷举框架」重新阅读体会一下。
第二种解法:因为这里 k 取值范围比较小,所以也可以不用 for 循环,直接把 k = 1 和 2 的情况手动列举出来也是一样的:
/**
* @Description: 方法二:状态机 压缩空间 —— 高效!!!
* @param {*}
* @return {*} 注意理解: 返回的一定是k=2状态,因为 每个状态机都代表——
* 当前状态下递推(递归似的)的既得收益!
* @notes: 关键:状态机穷举所有状态 + 使用所有选择 更新。
*/
int maxProfit(vector<int> &prices)
{
int n = prices.size();
int max_k = 2;
// base
// dp start
int dp_1_0 = 0, dp_1_1 = -prices[0], dp_2_0 = 0, dp_2_1 = -prices[0];
for (int i = 0; i < n; i++)
{
int temp1 = dp_1_0;
dp_1_0 = max(dp_1_0, dp_1_1+prices[i]);
dp_1_1 = max(dp_1_1, -prices[i]);
dp_2_0 = max(dp_2_0, dp_2_1+prices[i]);
dp_2_1 = max(dp_2_1, temp1-prices[i]); // 【关键!】主要是这里 k后到前 就不用temp1 新建了!
}
// return
return dp_2_0;
}
有状态转移方程和含义明确的变量名引导,相信你很容易看懂。如我我们想故弄玄虚,可以把上述四个变量换成 a, b, c, d。这样当别人看到你的解法时就会大惊失色,一头雾水,不得不对你肃然起敬。
这题和 k = 2 没啥区别,可以直接套上一题的第一个解法。但是提交之后会出现一个超内存的错误,原来是传入的 k 值可以任意大,导致 dp 数组太大了。现在想想,交易次数 k 最多能有多大呢?
一次交易由买入和卖出构成,至少需要两天。所以说有效的限制次数 k 应该不超过 n/2,如果超过,就没有约束作用了,相当于 k = +infinity。这种情况是之前解决过的。
优化/思考过程, 代码重用! 牛的!!!
直接把之前的代码重用:
/**
* @Description: 状态机方法: 注意区别于前面k=2;这次k不限定。 结合 `k=2&k=+inf` 做本题。
* @param {int} k
* @param {vector} &prices
* @return {*}
* @notes: 【关键】有个大问题:max_k太大容易 溢出!
* 优化思考:买入和卖出交易次数最大为 `n/2`;所以
*/
int maxProfit(int k, vector<int> &prices)
{
int n = prices.size();
if(n==0) return 0;
int max_k = k;
// 超出空间限制 k=inf+; 因为 k>=n/2 相当于无限次交易了!使用另一个 无限次交易的函数。
if(k >= n/2){
return maxProfitInf(prices);
}
// dp 数组
vector<vector<vector<int>>> dp(n, vector<vector<int>>(max_k + 1, vector<int>(2, 0)));
// base
// dp start
for (int i = 0; i < n; i++)
{
for (int k1 = max_k; k1 >= 1; k1--) // 正反都可以,因为 主要是下面状态压缩怕正反 修改数值。
{ // cuo wu
if (i - 1 == -1)
{
dp[i][k1][0] = 0;
dp[i][k1][1] = -prices[i];
continue; // cuo
}
dp[i][k1][0] = max(dp[i - 1][k1][0], dp[i - 1][k1][1] + prices[i]);
dp[i][k1][1] = max(dp[i - 1][k1][1], dp[i - 1][k1 - 1][0] - prices[i]);
}
}
// return
return dp[n - 1][max_k][0];
}
// helper——使用无限次交易的 状态机来帮助完成本题。
int maxProfitInf(vector<int> &prices)
{
// int maxPro = 0;
int dp_i_0 = 0, dp_i_1 = -prices[0];
for (int i = 0; i < prices.size(); i++)
{
int temp = dp_i_0;
dp_i_0 = max(dp_i_0, dp_i_1 + prices[i]); // 上个状态更新了,下面有用了!
dp_i_1 = max(dp_i_1, temp - prices[i]);
}
return dp_i_0;
}
至此,6 道题目通过一个状态转移方程全部解决。
本文给大家讲了如何通过状态转移的方法解决复杂的问题,用一个状态转移方程秒杀了 6 道股票买卖问题,现在想想,其实也不算难对吧?而这已经属于动态规划问题中较困难的了。
关键就在于找到所有可能的「状态」,然后想想怎么更新这些「状态」。一般用一个多维 dp 数组储存这些状态,从 base case 开始向后推进,推进到最后的状态,就是我们想要的答案。想想这个过程,你是不是有点理解「动态规划」这个名词的意义了呢?
关键还是在于:穷举所有 [状态] + [选择]地更新。
具体到股票买卖问题,我们发现了三个状态,使用了一个三维数组,无非还是穷举 + 更新,不过我们可以说的高大上一点,这叫「三维 DP」,怕不怕?这个大实话一说,立刻显得你高人一等有没有?
【总结】股票买卖的状态机 编程过程的重点:
- 明白
k
表示当前状态下所能达到的 最大收益。0
表示不交易最大收益就是0!- 每种题目的
base case
要会手动变形、写出。- 次重点: 区分好
状态压缩
时,变量 重用的错误!!! 顺序区分好,别被动更新为错误的数值了!
由于leetcode 此题目为会员题目。
因此详解见这篇文章:链接。