目录
引入
抛开效率来讲,我们的“枚举法”堪称无敌,无视一切防御。所以我们很自然的可以设计出下述代码。
int n, t;
int tcost[103], mget[103];
int ans = 0;
//当前位置pos 剩余时间tleft, 临时答案tans
//解决在T时间内能获取的最大值。
void dfs(int pos, int tleft, int tans) {
if (tleft < 0) return;//基本情况
if (pos == n + 1) {//更新答案
ans = max(ans, tans);
return;
}
dfs(pos + 1, tleft, tans);//不采取
dfs(pos + 1, tleft - tcost[pos], tans + mget[pos]);//采取
}
int main() {
cin >> t >> n;
for (int i = 1; i <= n; i++) cin >> tcost[i] >> mget[i];
dfs(1, t, 0);
cout << ans << endl;
return 0;
}
我们可以列出上述递归函数方程T(n) = 2 * T(n - 1)。
我们很容易就能得知,上述代码的时间复杂度为O()。这显然是不能通过我们的所有案例的。
优化
上面的做法为什么效率低下呢?因为同一个状态会被访问多次。
如果我们每查询完一个状态后将该状态的信息存储下来,再次需要访问这个状态就可以直接使用之前计算得到的信息,从而避免重复计算。这充分利用了动态规划中很多问题具有大量重叠子问题的特点,属于用空间换时间的「记忆化」思想。
具体到本题上,我们在朴素的 DFS 的基础上,增加一个数组 mem
来记录每个 dfs(pos,tleft)
的返回值。刚开始把 mem
中每个值都设成 -1
(代表没求解过,因为最大价值不可能为负值)。每次需要访问一个状态时,如果相应状态的值在 mem
中为 -1
,则递归访问该状态。否则我们直接使用 mem
中已经存储过的值即可。
通过这样的处理,我们确保了每个状态只会被访问一次,因此该算法的的时间复杂度为O(TM)
int n, t;
int tcost[103], mget[103];
int mem[103][1003];
int dfs(int pos, int tleft) {
if (mem[pos][tleft] != -1) //此前访问过,用记忆数组访问该状态
return mem[pos][tleft]; // 已经访问过的状态,直接返回之前记录的值
if (pos == n + 1) return mem[pos][tleft] = 0;//标记访问过了pos == n + 1的情况
int res1, res2 = -INF;//因为res2可能未被负值所以需要初始化。
res1 = dfs(pos + 1, tleft);//赋值res1--不采取
if (tleft >= tcost[pos])//如果可以采取
res2 = dfs(pos + 1, tleft - tcost[pos]) + mget[pos]; // 状态转移
return mem[pos][tleft] = max(res1, res2); // 最后将当前状态的值存下来
}
int main() {
memset(mem, -1, sizeof(mem));
cin >> t >> n;
for (int i = 1; i <= n; i++) cin >> tcost[i] >> mget[i];
cout << dfs(1, t) << endl;
return 0;
}
与递推的区别、联系
在求解动态规划的问题时,记忆化搜索与递推的代码,在形式上是高度类似的。这是由于它们使用了相同的状态表示方式和类似的状态转移。也正因为如此,一般来说两种实现的时间复杂度是一样的。
确切地说,此时我们的递推应该被称之为动态规划,再细致一下是背包DP中的一般的背包问题。在逻辑上,「记忆化」思想也是我们动态规划的底层思想。
这里有一个简单的例子:
在我们求解 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 时,我们很容易获取到了答案是8,但是如果我们说在上述计算式上再 + 1,那么我们也可以很快回答出答案是9。因为没有人会傻到再从头一个个去加,而是直接利用先前的式子答案8。这也正是我们动态规划的底层思想--记忆化。
记忆化搜索和动态规划的联系就在于处理的底层思维相同,至于区别则在于前者利用函数,后者利用循环式。
下面给出的是递推实现的代码(为了方便对比,没有添加滚动数组优化),通过对比可以发现二者在形式上的类似性。
const int maxn = 1010;
int n, t, w[105], v[105], f[105][1005];
int main() {
cin >> n >> t;
for (int i = 1; i <= n; i++) cin >> w[i] >> v[i];
for (int i = 1; i <= n; i++)
for (int j = 0; j <= t; j++) {
f[i][j] = f[i - 1][j];//不采取
if (j >= w[i])//如果可以采取
f[i][j] = max(f[i][j], f[i - 1][j - w[i]] + v[i]); // 状态转移方程--不采取VS采取
}
cout << f[n][t];
return 0;
}
实际上,上述的记忆数组f可以优化为一维。但由于今天的主角并非是背包DP,所以我们不给出优化的思路过程。(挖一个坑,悲)
再战经典
这道题的解题思路有许多,这里我们主要介绍记忆化搜索和DP的做法,其余的做法稍作解释。
闲言少叙,书归正传。我们将视线重新拉回正题。
对于这道题我们需要求解的是利润,而利润代表着一种“(净)变值”。所以我们可以从一个稍微不同的角度来看待这个问题。我需要求取的是某两天之间最大的净变值。继而我们将问题转置成为非空连续子序列的最大和。
既然问题转置成为一个很经典的问题--最大连续子序列和,那么如果我们想利用函数去解决的话,我们很清晰的知道我们可以采用分治策略+函数来实现。以下为一般代码:
class Solution {
int MaxSum (vector<int>& arr, int left, int right) {
if (left == right) return arr[left];//基本情况--一个元素
int mid = ( left + right ) / 2;
int left_sum = MaxSum(arr, left, mid);//答案可行方案1左区域得到最大和
int right_sum = MaxSum(arr, mid + 1, right);//答案可行方案2右区域获取最大和
//合并问题产生的答案可行方案--跨界
int cross_left = 0, cross_left_max = 0;
for (int i = mid; i >= 0; --i) {
cross_left += arr[i];
if (cross_left > cross_left_max) cross_left_max = cross_left;
}
int cross_right = 0, cross_right_max = 0;
for (int i = mid + 1; i <= right; ++i) {
cross_right += arr[i];
if (cross_right > cross_right_max) cross_right_max = cross_right;
}
return
max(max(right_sum, left_sum), max(cross_left_max + cross_right_max, 0));
}
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<int> diff(n);
diff[0] = 0;
for (int i = 1; i < n; ++i) {
diff[i] = prices[i] - prices[i-1];
}
return MaxSum(diff, 0, n - 1);
}
};
上述代码的时间复杂度O(n * logn),虽然在理论上我们可以处理 10^5 的数据量。但是这道题比较恶心,卡了一下算法过程。我们能够通过的案例大概是202/212。这也就是说,我们在大体上是对的。
其实这个算法并非很棒,他还是有在重复计算的。读者可以自行展开观察。这是直观地方式去看,如果是从时间复杂的表达式上去看呢?我们先给出时间复杂度的表达式。
事实上在 n > 1 时,我们的合并操作 O(N) 的时间可以通过记忆化去消除成为 O(1) 的时间。
代码如下:
class Solution {
//记录区间[l,r]需要记录的数据,标注如下
struct Status {
int lSum;//记录以左端l为起点的最大区间和
int rSum;//记录以右端点r为终点的最大区间和
int mSum;//记录最大区间和
int iSum;//记录区间合
};
Status get(vector<int> &a, int l, int r) {
if (l == r) {
return (Status) {a[l], a[l], a[l], a[l]};//一个元素时的状态
}
int m = (l + r) >> 1;
Status lSub = get(a, l, m);//获得左区间数据
Status rSub = get(a, m + 1, r);//获得右区间数据
//合并成新区间,也可以封装函数
//维护状态
int iSum = lSub.iSum + rSub.iSum;
int lSum = max(lSub.lSum, lSub.iSum + rSub.lSum);
int rSum = max(rSub.rSum, rSub.iSum + lSub.rSum);
//维护区间答案--3中答案可行方案
int mSum = max(max(lSub.mSum, rSub.mSum), lSub.rSum + rSub.lSum);
return (Status) {lSum, rSum, mSum, iSum};
}
Status MaxSum(vector<int>& arr, int left, int right){
return get(arr, left, right);
}
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<int> diff(n);
diff[0] = 0;
for (int i = 1; i < n; ++i) {
diff[i] = prices[i] - prices[i-1];
}
Status ans = MaxSum(diff, 0, n - 1);
return max(0, ans.mSum);
}
};
至此,我们通过记忆化的手段将复杂度降低为 O(N) 。即见记忆化搜索,何不探求动态规划。没错对于最大子序列和我们也是有动态规划算法的。我们通过压缩区间的方式实现。
详见我的另一篇博客经典题:最大子序列和。
int MaxSum(const vector<int>& arr) {
int max;
int n = arr.size();
vector<int> sum(n);
max = sum[0] = arr[0];//初始化
//动态规划,因为压缩的是列信息所以从前往后
for(int i = 1; i < n; ++i)
{
sum[i] = max(sum[i-1] + arr[i], arr[i]);
max = max(max, sum[i]);
}
return max;
}
当然,我们从时候的角度上来说。净变化之和其实就是 末位元素 - 首位元素 而已。所以,你可能会见到另一份DP代码,那也是正确的。那也仅仅是做了一些数学公式变形。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int inf = 1e9;
int minprice = inf, maxprofit = 0;
for (int price: prices) {
maxprofit = max(maxprofit, price - minprice);//答案产生于净变值和即 末位元素 - 起始元素
minprice = min(price, minprice);//标记历史最低--标记起始元素
}
return maxprofit;
}
};
题外解法
这道题我们从人类的贪婪角度,我们自然希望是"低价买入,高价卖出"了。所以我们需要维护一个过程、区域的有序性,那么我们自然想到使用“栈”。利用“栈”来完成优先级匹配关系。
在这里,我们需要标记处一段时间内的增值情况,所以在栈内应该维护一个升序关系。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int ans = 0;
vector<int> Stk;//使用数组模拟栈
prices.emplace_back(-1); //为了计算prices[n-1]的情况,使用-1标记
for (int i = 0; i < prices.size(); ++ i){
while (!Stk.empty() && Stk.back() > prices[i]){ // 维护单调栈升序,如果压入元素小需要抛出栈顶并计算答案
ans = std::max(ans, Stk.back() - Stk.front()); // 维护最大值
Stk.pop_back();
}
Stk.emplace_back(prices[i]);
}
return ans;
}
};