在Acwing学习和leecode做题的过程中发现很多需要依据某种顺序求值得的题目都可以使用单调性优化,故此总结。
单调栈
leecode 121.购买股票的最大时机
这个题其实完全可以遍历一遍求解,开始想的时候用了单调栈,耗时112ms,没有直接遍历效果好,权当练习。
首先,买股的时候必须要比卖股票的股价低,且必须要在卖股之前,抽象出来就是寻找某数之前的最小值。
如果栈不为空且栈顶元素比当前元素大,就把栈顶弹出来,因为显然在后续的比较中也不需要。如果后面的元素比栈顶元素大,肯定也比当前元素大,且二者差更大,更贴合需求。
在处理结束后,栈顶元素一定是在遍历到的某数之前最小的元素,做差并记录。
class Solution {
public:
//我盲猜这是一个单调栈
stack<int>stock;
int res = 0;
int maxProfit(vector<int>& prices) {
for(int i = 0; i < prices.size(); i++){
while(!stock.empty() && stock.top() >= prices[i])stock.pop();
if(!stock.empty())res = max(res, prices[i] - stock.top());
if(stock.empty())stock.push(prices[i]);
}
return res;
}
};
leecode84. 柱状图中最大的矩形
开始我以为这是跟接雨水一样,用的是双指针思想,后来看完题解,,,是单调栈。
根据题解,采用确定宽度寻找高度的方法或者确定高度寻找宽的的算法,二者都是O(n2),因此对确定高度寻找宽度的方法做优化。
首先,这里的左右边界都是不包含的开区间,当后面的高度比前面小时,例如6和2,则我们已经找到了一个右边界(因为显然此时计算的时候只能通过2的高度来计算,原理类似与短板效应)。若后面的高度比前面要大时,我们可以继续入栈,因为后面入栈的与先前的相邻,并且可以贡献面积。
因此当我们找到矩形的右边界时,便可以通过弹出单调栈寻找到其左边界(寻找到离当前右边界最远(小)的数值)
代码如下:
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int ret;
stack<int>s;
heights.insert(heights.begin(),0);
heights.emplace_back(0);
//这里需要给数组插入左右0作为边界,防止最右或最左需要在面积中计算的情况
for(int i = 0;i < heights.size();++i){
while(s.size() && heights[s.top()] > heights[i]){
int cur = s.top();
s.pop();
//这里的left表示的是左边的边界,要找到一个最左边的前一个数
int left = s.top();
ret = max(ret,(i - left -1) * heights[cur]);
}
stack.emplace(i);
}
return ret;
}
};
leecode .739
这次倒是看出来是个单调栈了,但是不知道怎么记录天数差值,因此一开始开了两个栈去维护,然后写乱了。
需要利用单调性又需要记录元素记录的情况下,记录下标单调栈
vector<int> dailyTemperatures(vector<int>& temperatures) {
int n = temperatures.size();
vector<int>ans(n);
stack<int>s;
for(int i = 0; i < n; i++){
while(s.size() && temperatures[i] > temperatures[s.top()]){
int preIndex = s.top();
ans[preIndex] = i - preIndex;
s.pop();
}
s.push(i);
}
return ans;
}
单调队列
leecode 239.滑动窗口最大值
就某一个窗口而言,将其构造成一个单调递减的队列。
以序列[1,3,-1,-3,5,3,6,7] 为例,假设窗口大小为3
原理:首先我们可以看出,当即将要进入窗口的数字比窗口末端小时,进来没有意义。因为窗口滑动过程中不可能把当前的的数滑出去,就算进来a了最大的依旧是3,因此做单调性优化如下:
- 采用优先队列存储序列元素的下标,使用相对位置控制窗口大小
- 如果即将进入的元素b比窗口末端的元素a大,那就删除末端a元素(因为就算a在里面,等到滑到ab所在的框的时候,最大的依旧是b,a没有必要留着)
- 最终窗口是一个单调递减的队列,输出队头即最大值
const int N = 1e5 + 10;
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
vector<int>res;
int q[N];
int hh = 0, tt = -1;
for(int i = 0; i < nums.size(); i++){
if( hh <= tt && i - k + 1 > q[hh])hh++; //保障窗口
while( hh <= tt && nums[q[tt]] <= nums[i])tt--;//如此元素大于队列(窗口)内元素,删除
q[++tt] = i; //加入元素,此元素一定小于队列内的元素
if( i >= k - 1)res.push_back(nums[q[hh]]);
}
return res;
}
这里最后想说,因为队列无法删除最后一个元素,双端队列效率极低,回归了acwing版本的数组模拟队列。还是得掌握数组模拟的方法,毕竟stl有时候满足不了时间需求。
c++的数组好哇。
leecode 300.最长上升子序列
这里不侧重dp,主要介绍类似贪心的优化方法。首先,对于某一个上升子序列(y总视频讲解图)
我们在遍历时只需要记录1,因为能加在3后面的一定能加在1后面,同时1的可扩展性更好,能后缀的子序列更长,因此仅记录1。
对于子序列而言,假如最长上子序列长度为1,就将每个数作为其所在的长度为1的序列最小的值,因此,这里定义数组q[n], 记录的就是每种长度的序列最小的结尾值。(有点绕,举个例子,加入长度为3的上升序列有 1 2 8, 1 5 6,此处 q[3] = 6,因为有6比8扩展性更好,原则上可以后缀更长的序列)
按照此种方式记录的子序列,n越长,结尾的值是单调递增的。(证明很简单。假如n=6的子序列结束值小于 n = 5的子序列结束值,又因为子序列是单调增的,那么 n = 6的第五个值一定小于末尾值,故一定小于 n = 5结束值,因此 n = 5的结束值就不是所有 同长度的结束值里最小的了,与定义矛盾,故得证。)
此时,我们要将ai插入某个序列中,一定是找小于ai的最大数 作为结尾值 所在的序列里,这里使用二分来寻找 ≤ai 的最大数
假设找到的最大≤ai 数组是q[4], 则q[5]一定是 ≥ai 的,因此将ai 加到长度为4的子序列后,该长度变成了5,此时有了两个n = 5的子序列,最小的结尾值是ai,因此这里直接将ai更新到q[5]去。
int lengthOfLIS(vector<int>& nums) {
vector<int>q(nums.size() + 1, 0);
int len = 0;
q[0] = -2e9; //q[0]是负无穷,小于所有数,一定可以被加上新的数字
for(int i = 0; i < nums.size(); i++){
int l = 0, r = len;
while(l < r){
int mid = (l + r + 1) >> 1;
if(q[mid] < nums[i])l = mid;
else r= mid - 1;
}//这里要讲nums[i]插到结尾去,要找小于nums[i]的最大值
len = max(len, r + 1);
q[r + 1] = nums[i];//跟新结尾值
}
return len;
}