文章目录
栈
一般计算表达式问题都可以使用栈来解决。
简化路径问题(经典一)
简化路径
(stringstream + getline忽略‘/’)
使用stringstream
直接忽略/
是最好的做法,这样就避免了/
的判断,而直接判断有效字符串。
1.如果是当前目录.
,就直接跳过
2.如果是返回上级目录,就将栈顶元素也就是上一次有效字符串删除掉
3.否则直接将有效字符串放入栈中
注意这里使用stringstream
的方法,就是从将一个表达式的内容以string
的形式存储起来。
还有的getline
的使用, getline(iostream& sin, string& tmp,[char del])
,就是将sin
中的内容输入给tmp
中,然后以del
字符作为一行结束的符号,如果del
不写就默认是\n
,这里可以使用\
作为一行结束的标志。
class Solution {
public:
string simplifyPath(string path) {
stringstream sin(path);// 将path中的内容以string的方式存在sin中
string tmp;
stack<string> sk;
// 每一次tmp从sin中提取一行字符串并且以'/'作为结束(重点)
while (getline(sin, tmp, '/')) {
if (tmp == "" || tmp == ".") {// 如果是'/'(也就是"")或者是"."就忽略,
continue;
} else if (tmp == "..") { // 如果是".."就返回上一级
if (!sk.empty()) // 如果没有上一级就直接返回
sk.pop();
continue;
} else {// 如果是路径就直接放入栈中
sk.push(tmp);
}
}
string ans;
while (!sk.empty()) {// 从栈中取出字符串拼接ans,并且每一次都要使用'/'连接上
ans = "/" + sk.top() + ans ;
sk.pop();
}
if (ans.empty()) return "/";// 如果没有任何的路径,就返回根目录
return ans;
}
};
有效括号
(栈)
因为相邻的两个左右括号一定要匹配,否则就视为不合法。利用这个思路就可以使用一个栈来判断相邻的两个括号。
首先需要判断遍历的字符是左括号还是右括号(注意左括号需要入栈,右括号只需要匹配栈中的左括号而已,判断之后就不需要入栈了),如果是左括号就入栈等待判断。如果是右括号,就需要判断栈顶元素的情况,如果没有栈顶元素,说明不可能有一个左括号在该括号的左边与之匹配。如果有栈顶元素,就需要判断左右括号是否匹配。
class Solution {
public:
bool isValid(string s) {
stack<char> sk;
for (char ch : s) {
if (ch == '(' || ch == '[' || ch == '{') {
sk.push(ch);
} else {
// 如果有右括号就一定要有左括号,否则直接返回false
if (sk.empty()) return false;
if (ch == ')' && sk.top() != '(') return false;
else if (ch == ']' && sk.top() != '[') return false;
else if (ch == '}' && sk.top() != '{') return false;
sk.pop();
}
}
return sk.empty() ? true : false;
}
};
(哈希表+栈)
上面纯使用栈来判断括号的时候,在判断遍历的字符是左括号还是右括号的时候,需要重复的判断很麻烦,所以可以使用哈希表使得每一个左括号和与之匹配的括号形成对应的匹配值{')', '('}, {']', '['}, {'}', '{'}
,这样不仅在判断是否为左右括号的时候方便了(直接使用hash.count()
即可)而且与括号匹配的括号就是对应的键值hash[ch]
。
class Solution {
public:
bool isValid(string s) {
unordered_map<char, char> hash = {{')', '('}, {']', '['}, {'}', '{'}};
stack<char> sk;
for (char ch : s) {
if (hash.count(ch)) {
if (sk.empty() || sk.top() != hash[ch]) return false;
sk.pop();
} else {
sk.push(ch);
}
}
return sk.empty();
}
};
逆波兰表达式
(栈)
逆波兰表达式是一种后缀表达式,所以正好符合栈的计算。
遍历数组的时候,如果是数字(这里的数字指的是转化成字符串的数字)就将数字放入栈中,如果是计算符号,就要计算前两个在栈中的数字,将数字取中并判断符号以后计算即可。
要注意的是需要判断数字的时候不能使用像isdigit()
isalnum
isalpha
这类的函数,因为这c标准库中这些函数只能判断字符,而不能判断字符串。
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> sk;
for (string s : tokens) {
if (s == "+" || s == "-" || s == "*" || s == "/") {
int a = sk.top(); sk.pop();
int b = sk.top(); sk.pop();
int tmp;
if (s == "+") tmp = b + a;
else if (s == "-") tmp = b - a;
else if (s == "*") tmp = b * a;
else tmp = b / a;
sk.push(tmp);
} else {
sk.push(stoi(s));
}
}
return sk.top();
}
};
取出重复字母保留字典序最小问题(经典二)
去除重复字母
(单调栈+贪心+哈希表)
主要的思想就是:保存一个ASCII分段单调递增的栈。
回答一下3个问题就可以是完成这道题目。
1.为什么使用栈?
因为需要字符串的字符顺序不能变,所以从前往后遍历依次保存字母,如果发现ASCII更小的字母就需要将前面ASCII大的字母删掉,这样先让字母进入,然后再让字母删去,并且是先进后出的方式就是栈的特性,所以可以使用栈。
2.为什么保存单调递增栈?
因为题目要求返回的字符串的字典序尽可能的最小,并且字符串的字符顺序不能变,所以需要遍历字符串的时候,注意ASCII值更小的字符放在前面把ASCII值大的字母顶替掉,这样才可以使得字典序最小。可以发现如果遇到ASCII值小的字母就让一直往前替换掉前面ASCII值大的字母,所以栈中保存的一直是一个ASCII递增的字母(因为有递减的部分,都替换点前面ASCII值大的部分了)
3.为什么是分段单调递增,而不是全栈单调递增?
刚刚说只要遇到ASCII值小的字母就一直往前将ASCII大的字母替换掉其实是有问题的,因为题目中还有要求就是让字符串去重后字典序最小,换言之就是字符串原有的字母一个都不能少,并且不能重复出现相同的字母。如果题目只是要求要保证字典序最小,可以像问题2中的所说的一直替换,但是既然有限制就这样做。而是每一次替换的时候,往字符串后面看看还有没有和准备替换的栈顶相同的字母,如果有那么可以替换,因为后面还可以接上,但是如果没有的话,为了保证字符串的完整性就不能替换了。最后说回来,知道为什么是分段递增了吧,就是因为可能前面的字母已经在一个ASCII更小的字母后面没有了,所以为了保证字符串的完整性,所以就只能将ASCII小的字母接在ASCII大的字母后面,然后再这个字母的后面再保持ASCII递增的归墟。
4.补充问题3
为了保证字符串的完整性和字典序的最优性,需要在判断已经入栈的字母不能再次入栈。因为相同字母再次入展并不会让字符串的字典序变成更优,只是回到和以前的优先级一样的位置。但是致命的是在该字母替换前面ASCII大的字母的时候会将哪些后面不会再出现的字母删除掉,这样字符串虽然不会有重复字母了,同时字符串的完整性也不能保证了。综上需要判断已经入栈的字母不能再次入栈。只有ASCII大的字母被替换的时候,才可以在后面再次被选取
补充coding技巧:可以使用哈希表记录字母出现的频率,来判断是否某一个字母的后面还有前面的字母。另一种凡是就是使用一个数组lastIndex
来记录一个字母最后出现的位置,这样直接比对数组的下标就可以知道与该字母相同的字母是否存在。
class Solution {
public:
string smallestSubsequence(string s) {
unordered_map<char, int> hash;
for (char ch : s) {
hash[ch] ++;
}
stack<char> sk;
vector<bool> vis(26, false);
for (char ch : s) {
hash[ch] --;
if (vis[ch - 'a']) continue;// 如果已经入栈的字母后面就不可以在入栈
while (!sk.empty() && sk.top() > ch && hash[sk.top()] > 0) {
vis[sk.top() - 'a'] = false;
sk.pop();
}
sk.push(ch);
vis[ch - 'a'] = true;
}
string ans;
while (!sk.empty()) {
ans = sk.top() + ans;
sk.pop();
}
return ans;
}
};
另一种写法:
class Solution {
public:
string removeDuplicateLetters(string s) {
int len = s.size();
vector<int> lastIndex(26, 0);
for (int i = 0; i < len; i ++) {
lastIndex[s[i] - 'a'] = i;// s[i]最后出现的位置
}
stack<char> sk;
vector<bool> vis(26, false);
for (int i = 0; i < len; i ++) {
if (vis[s[i] - 'a']) continue;
while (!sk.empty() && s[i] < sk.top() && lastIndex[sk.top() - 'a'] > i) {
vis[sk.top() - 'a'] = false;
sk.pop();
}
sk.push(s[i]);
vis[s[i] - 'a'] = true;
}
string ans;
while (!sk.empty()) {
ans = sk.top() + ans;
sk.pop();
}
return ans;
}
};
单调栈(经典三)
单调栈:就是一个普通的栈,只不过栈中的元素保持了一定的单调性。一般可以解决:快速找到当前元素左边第一个比当前元素大(单调递减)或者小(单调递增)的元素;对应的也是可以快速地找到当前元素右边第一个比当前元素小(单调递减)或者大(单调递增)的元素。俗称为:Next Greater Element
使用条件:
1.遍历数字或者字符串的时候,必须要从前往后或者从后往前顺序遍历,满足元素是依次进入的条件
2.元素可以使用某一种性质的单调性来筛选元素。
接雨水
(单调栈)
使用一个单调递减的栈来保存柱子的下标。
1.使用栈是因为一旦遇到更高的柱子就会将原来低的柱子抵消掉形成低洼,满足这样后进先出的性质的数据结构可以使用栈。
2.使用单调栈是因为高柱子会和栈顶的低柱子抵消掉,所以只有低柱子才会被保留下来,而高柱子因为会形成低洼就不会保留下来。
class Solution {
public:
int trap(vector<int>& height) {
int len = height.size();
if (len < 3) return 0;
stack<int> sk;
int ans = 0;
for (int i = 0; i < len; i ++) {
while (!sk.empty() && height[sk.top()] < height[i]) {
int top = sk.top();
sk.pop();
if (sk.empty()) break;
int w = i - sk.top() - 1;
int h = min(height[sk.top()], height[i]) - height[top];
ans += w * h;
}
sk.push(i);
}
return ans;
}
};
(双指针)
1.使用双指针是因为木桶原理,如果两个指针分别指向数组两端的柱子,那么只能是低的那个柱子向中间移动才可能使得盛得的雨水更多,因为由木桶可知,雨水的量是由低的柱子决定的,所以主要观察到哪一端的柱子低,就可以计算哪一段盛得的雨水。
2.在已经确定哪一端的柱子低之后,不是任何情况都可以计算雨水,因为确定了哪一端的柱子低只能说明雨水由这一端决定,但是如果这一端的雨水不能形成低洼,也就是当前柱子的高度是前面所有没有计算过雨水的柱子中最大的一个,这时没有形成低洼就不可以计算雨水,只能更新柱子的最大值,等待低洼的出现。
class Solution {
public:
int trap(vector<int>& height) {
int len = height.size();
if (len < 3) return 0;
int left = 0, right = len - 1;
int left_max = 0, right_max = 0;
int ans = 0;
while (left < right) {
if (height[left] < height[right]) {
if (height[left] < left_max) {
ans += left_max - height[left];
} else {
left_max = height[left];
}
left ++;
} else {
if (height[right] < right_max) {
ans += right_max - height[right];
} else {
right_max = height[right];
}
right --;
}
}
return ans;
}
};
(暴力)TLE
暴力方法就是计算每一个柱子的左右最大的柱子去一个最小值,这样就可以计算出当前位置上可以盛多少雨水,但是计算左右的柱子最大值很耗时间。
class Solution {
public:
int trap(vector<int>& height) {
int len = height.size();
if (len < 3) return 0;
int ans = 0;
for (int i = 0; i < len; i ++) {
int left_max = 0, right_max = 0;
for (int left = 0; left <= i - 1; left ++) {
left_max = max(left_max, height[left]);
}
for (int right = i + 1; right < len; right ++) {
right_max = max(right_max, height[right]);
}
int height_max = min(right_max, left_max);
if (height_max > height[i]) {
ans += height_max - height[i];
}
}
return ans;
}
};
(动规优化暴力)
计算左右的最大值可以提前计算好记录下来,这样就可以空间换时间的优化暴力算法。
class Solution {
public:
int trap(vector<int>& height) {
int len = height.size();
if (len < 3) return 0;
vector<int> left_max(len, 0);
// [0, i)中最大的柱子 = max([0, i - 1)中最大的柱子, 第i-1个柱子)
for (int i = 1; i <= len - 2; i ++) {
left_max[i] = max(left_max[i - 1], height[i - 1]);
}
vector<int> right_max(len, 0);
// (i, len)中最大的柱子 = max((i + 1, len)中最大的柱子, 第i+1个柱子)
for (int i = len - 2; i >= 1; i --) {
right_max[i] = max(right_max[i + 1], height[i + 1]);
}
int ans = 0;
for (int i = 1; i <= len - 2; i ++) {
int height_max = min(left_max[i], right_max[i]);
if (height_max > height[i]) {
ans += height_max - height[i];
}
}
return ans;
}
};
每日温度
(暴力)
暴力方法很容易想到,就是遍历每一个数然后再到当前位置的后边找第一个比当前数字大的数字,然后记录下距离差即可。
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
int len = temperatures.size();
vector<int> ans(len, 0);
for (int i = 0; i < len; i ++) {
int j = i;
while (j < len && temperatures[j] <= temperatures[i]) j ++;
if (j != len)
ans[i] = j - i;
}
return ans;
}
};
(单调递增栈)
本题可以顺序遍历每一个数字并且后面的数字可以利用单调性(温度升高)使得前面的数字弹出。所以可以使用单调栈来找到右边第一个比当前元素大的元素。有两种实现方式。
第一种是从后往前遍历保留最大值,每当一个小的元素出现的时候,可以记录小元素和栈顶元素的位置的差距。
第二种是从前往后遍历也就保留最大值,当遇到最大值的时候,就不断的弹出前面小的元素并记录位置的差距。
综上:一个是保留右边的最大值,然后进入栈的元素直接计算位置的差距;一个是当出现最大值的时候计算前面小元素和当前位置的差距。无论是哪一种都是利用了温度升高的单调性来使得元素不断地进入和弹出。
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
int len = temperatures.size();
vector<int> ans(len, 0);
stack<int> sk;
for (int i = len - 1; i >= 0; i --) {
while (!sk.empty() && temperatures[i] >= temperatures[sk.top()]) {
sk.pop();
}
if (!sk.empty()) {
ans[i] = sk.top() - i;
}
sk.push(i);
}
return ans;
}
};
写法二:从前往后
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
int len = temperatures.size();
vector<int> ans(len, 0);
stack<int> sk;
for (int i = 0; i < len; i ++) {
while (!sk.empty() && temperatures[i] > temperatures[sk.top()]) {
ans[sk.top()] = i - sk.top();
sk.pop();
}
sk.push(i);
}
return ans;
}
};
(dp思想)
如果在暴力方法上优化,可以发现后很多重复遍历的地方。
所以当向右遍历到一个小的元素的时候,不用再一个一个遍历比这个小元素还小的元素,而是直接找到这个小元素大的元素,这样就可以节省了不少的时间。
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
int len = temperatures.size();
vector<int> ans(len, 0);
for (int i = len - 2; i >= 0; i --) {
int j = i + 1;
while (j < len) {
if (temperatures[j] > temperatures[i]) {
ans[i] = j - i;
break;
} else if (ans[j] == 0) {
ans[i] = 0;
break;
}
j += ans[j];
}
}
return ans;
}
};
总结每日温度:使用暴力算法,每一次都要重复的遍历重复的数字去寻找大的元素,这样效率不高。如果使用利用已经计算过的答案就可以直接跳过很多元素就可以小路提高。但是最好的还是单调栈直接记录了右边最大值的位置,这样就可以进一步的提高效率了。
下一个更大元素 l
(暴力 + 哈希表)
使用暴力方法就是直接两层循环向右找每一个元素的右边对应的最大值,并使用哈希表记录下来。
class Solution {
public:
vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
int len1 = nums1.size();
int len2 = nums2.size();
// 使用哈希表记录下nums2中暴力查找出的每一个对应数右边更大的数字方便查找
unordered_map<int, int> hash;
for (int i = 0; i < len2; i ++) {
for (int j = i + 1; j < len2; j ++) {
if (nums2[j] > nums2[i]) {
hash[nums2[i]] = nums2[j];
break;
}
}
}
// 到nums1中对应的位置上输出对应的数的更大的元素
vector<int> ans(len1, 0);
for (int i = 0; i < len1; i ++) {
if (hash[nums1[i]] != 0) {
ans[i] = hash[nums1[i]];
} else {
ans[i] = -1;
}
}
return ans;
}
};
(单调栈 + 哈希表)
可以使用单调栈来记录右边第一个最大的元素的值或者值对应的下标。
这里采用正序遍历,即遇到更大元素的时候,就标记栈顶元素中所有比该元素小的元素右边的最大值是当前元素的值(这里可以使用哈希表记录下来),但是有一点需要注意,就是有一些元素右边没有更大的值,这些元素最后就都会剩下来放在栈中,可以将这些值对应的右边最大元素标记为-1
。
class Solution {
public:
vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
int len1 = nums1.size();
int len2 = nums2.size();
stack<int> sk;
unordered_map<int, int> hash;
for (int i = 0; i < len2; i ++) {
while (!sk.empty() && nums2[i] > nums2[sk.top()]) {
hash[nums2[sk.top()]] = nums2[i];
sk.pop();
}
sk.push(i);
}
vector<int> ans(len1, 0);
for (int i = 0; i < len1; i ++) {
if (hash[nums1[i]] != 0) {
ans[i] = hash[nums1[i]];
} else {
ans[i] = -1;
}
}
return ans;
}
};
(单调栈【逆向遍历】 + 哈希表)
使用逆向遍历一般保存的都是右边的最大值,所以所有的元素的右边的最大值就是sk.top()
下标对应的值,但是为了防止栈中没有元素的情况也就是右边没有最大值的情况,所以需要对栈判一个空hash[nums2[i]] = sk.empty() ? -1 : nums2[sk.top()]
,这样就保证遍历一遍之后所有的值都对应起右边的最大值元素。
class Solution {
public:
vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
int len1 = nums1.size();
int len2 = nums2.size();
stack<int> sk;
unordered_map<int, int> hash;
for (int i = len2 - 1; i >= 0; i --) {
while (!sk.empty() && nums2[i] >= nums2[sk.top()]) {
sk.pop();
}
hash[nums2[i]] = sk.empty() ? -1 : nums2[sk.top()];
sk.push(i);
}
vector<int> ans(len1, 0);
for (int i = 0; i < len1; i ++) {
ans[i] = hash[nums1[i]];
}
return ans;
}
};
总结:寻找左边或者右边第一个更大或者更小的元素通常使用单调栈解法,而且可以注意到使用正序遍历的方法,让大元素的逼小元素出栈,这种逼迫法会有一些元素最后不能及时处理,需要手动的二次处理。而逆序遍历这样保留最大值的“保留法”相对而言就会在循环中直接将所有的元素对应的第一个更大值全部都记录下来,更加简单一点。
下一个更大元素 ll
(单调栈 + 暴力)
有下一个更大元素l可以知道可以使用单调栈的找到右边的最后一个最大数。另外本题说明了数组是一个循环数组,换言之,如果一个数字如果右边找不到更大的元素可以往前再去找更大的数字,如果前面也没有赋值为-1。
所以可以标记没有赋值的数字,重头循环载找一次即可。(注意因为数组中的数字可以是负数,所以这里要给没有更大元素的位置赋值为INT_MIN
,防止冲突)
class Solution {
public:
vector<int> nextGreaterElements(vector<int>& nums) {
int len = nums.size();
stack<int> sk;
vector<int> ans(len, 0);
for (int i = len - 1; i >= 0; i --) {
while (!sk.empty() && nums[i] >= nums[sk.top()]) {// 注意这里是>=
sk.pop();
}
ans[i] = sk.empty() ? INT_MIN : nums[sk.top()];// 判断是否有更大元素
sk.push(i);
}
for (int i = 0; i < len; i ++) {
if (ans[i] == INT_MIN) {// 如果标记过就往前找
for (int j = 0; j < i; j ++) {
if (nums[j] > nums[i]) {
ans[i] = nums[j];
break;
}
}
if (ans[i] == INT_MIN) ans[i] = -1;// 如果还是没有找到就赋值为-1
}
}
return ans;
}
};
(单调栈 + 循环数组)
当然对于这样往前重头寻找数字有一种方法就是在数组的后面再接上一个一样的数组,这样就可以形成一个线性的循环数组(当然只能循环一次)。
class Solution {
public:
vector<int> nextGreaterElements(vector<int>& nums) {
int len = nums.size();
vector<int> ans(len, 0);
stack<int> sk;
for (int i = 2 * len - 1; i >= 0; i --) {
while (!sk.empty() && nums[i % len] >= nums[sk.top() % len]) {
sk.pop();
}
ans[i % len] = sk.empty() ? -1 : nums[sk.top() % len];
sk.push(i);
}
return ans;
}
};
变形一:可以改为直接存储数值
class Solution {
public:
vector<int> nextGreaterElements(vector<int>& nums) {
int len = nums.size();
vector<int> ans(len, 0);
stack<int> sk;
for (int i = 2 * len - 1; i >= 0; i --) {
while (!sk.empty() && nums[i % len] >= sk.top()) {
sk.pop();
}
ans[i % len] = sk.empty() ? -1 : sk.top();
sk.push(nums[i % len]);
}
return ans;
}
};
可以改为正序遍历:
注意:在正序遍历的时候,最后栈中的元素右边都是没有更大值的,所以需要处理最后剩下栈中的元素标记为-1,所以为了方便起见所以直接将ans
数组直接全部初始化为-1,这样还要没有处理的数字都对应-1。
class Solution {
public:
vector<int> nextGreaterElements(vector<int>& nums) {
int len = nums.size();
vector<int> ans(len, -1);// 这里要初始话为-1
stack<int> sk;
for (int i = 0; i < 2 * len; i ++) {
while (!sk.empty() && nums[i % len] > nums[sk.top()]) {// 注意这里是>
ans[sk.top()] = nums[i % len];
sk.pop();
}
sk.push(i % len);
}
return ans;
}
};
柱状图中最大的矩形
(暴力枚举矩形的宽)TLE
首先可以想到的暴力方法是以第i
个柱子的高度为矩形的高不变,然后向左和右拓展探寻矩形的最大宽度,这样枚举了所有的柱子之后就可以找到最大的矩形面积了。
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int len = heights.size();
int ans = 0;
for (int i = 0; i < len; i ++) {
int left = i;
while (left >= 0 && heights[i] <= heights[left]) {
left --;
}
left ++;
int right = i;
while (right < len && heights[i] <= heights[right]) {
right ++;
}
right --;
ans = max(ans, (right - left + 1) * heights[i]);
}
return ans;
}
};
(暴力枚举矩形的高)TLE
除了固定一个柱子的高度,还可以循环矩形的宽度,然后再这样宽度下使用已经枚举过的高度的最小值作为矩形的高度即可。这样
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int len = heights.size();
int ans = 0;
for (int left = 0; left < len; left ++) {
int h_min = heights[left];
for (int right = left; right < len; right ++) {
h_min = min(h_min, heights[right]);
ans = max(ans, (right - left + 1) * h_min);
}
}
return ans;
}
};
(单调栈朴素版本)
从上面第一种暴力方法可以看出只需要求出以一个矩形的高度为基准求出矩形的宽度即可。而一个柱子的左右两端小于当前柱子的高度的位置就是这个矩形的宽度的最大范围。
这是就需要联想一下以前学习的知识:需要快速的求出左右两端小于当前值的两个数值的方法是使用单调栈。
所以可以使用单调栈求出右边更小元素的下标,而左边更小元素的下标就是入栈时候的元素(下标)。因为维护的是一个单调递增的栈,所以栈中比当前元素更大的元素已经被当前元素”逼出“栈了,留下的元素就是当前元素左边更小的元素。
注意:因为取left
的时候,栈中可以没有元素,那么左边界就是-1。如果右边没有更小的元素,那么右边界就是len
,方便起见一开始就全部初始化left
为全-1
,right
全为len
。
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int len = heights.size();
if (len == 1) return heights[0];
vector<int> left(len, -1);
vector<int> right(len, len);
stack<int> sk;
for (int i = 0; i < len; i ++) {
while (!sk.empty() && heights[sk.top()] >= heights[i]) {
right[sk.top()] = i;
sk.pop();
}
if (!sk.empty()) {
left[i] = sk.top();
}
sk.push(i);
}
int ans = 0;
for (int i = 0; i < len; i ++) {
ans = max(ans, (right[i] - left[i] - 1) * heights[i]);
}
return ans;
}
};
(单调栈)
当然可以不用等全部求出所有的柱子的左右边界之后再求矩形的面积。也可以一知道左右边界之后就立刻求出对应的矩形。
但是因为栈的延时性和不完整性,所以没有办法直接处理矩形没有左边界或者右边界的情况,所以引发了两个特判。
1.在while
循环内部找到右边界之后,需要特判是否栈中为空,如果为空就说明矩形的左边界为-1
,否则就是sk.top()
。
2.可能会剩下一些单调递增的元素没有矩形的右边界形成不了矩形。就需要将右边界都设置成为len
即可。
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int len = heights.size();
if (len == 1) return heights[0];
stack<int> sk;
int ans = 0;
for (int i = 0; i < len; i ++) {
while (!sk.empty() && heights[i] < heights[sk.top()]) {
// i是右边界,sk.top()是左边界(如果没有sk.top()则左边界为-1)
int top = sk.top();
sk.pop();
int w = 0;
if (sk.empty()) {// 特判栈中的唯一一个元素
w = i;// 这里其实是i - (-1) - 1;
} else {
w = i - sk.top() - 1;
}
int h = heights[top];
ans = max(ans, w * h);
}
sk.push(i);
}
// 处理剩下单调递增的节点
while (!sk.empty()) {
int top = sk.top();
sk.pop();
int w = 0;
if (sk.empty()) {
w = len;
} else {
w = len - sk.top() - 1;
}
int h = heights[top];
ans = max(ans, w * h);
}
return ans;
}
};
(单调栈 +1个 哨兵节点)
我们可以发现处理单调递增元素的数字的时候,其逻辑和while
循环内部的逻辑几乎相同,只不过矩形的右边界全为len
而已。所以动动脑经,能不能将两段代码何在一起写。
答案是:当然可以,可以使用一个“0”将单调递增的元素都逼出来。因为“0“一定是高度最小的(当然使用更小的数字也是可以的,例如”-1“),所以一定可以作为矩形的右边界。这里添加的0,就是一个占位符同时将所有剩余的元素都逼出来计算而已。但是可以省去一大堆冗余的代码,这样的节点叫做哨兵节点(防止特判,但是本身并没有实际意义)。
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int len = heights.size();
if (len == 1) return heights[0];
heights.push_back(0);// 在最后条件一个哨兵
stack<int> sk;
int ans = 0;
for (int i = 0; i <= len; i ++) {
while (!sk.empty() && heights[i] < heights[sk.top()]) {
int top = sk.top();
sk.pop();
int w = 0;
if (sk.empty()) {// 特判栈中的唯一一个元素
w = i;
} else {
w = i - sk.top() - 1;
}
int h = heights[top];
ans = max(ans, w * h);
}
sk.push(i);
}
// 不需要在处理节点,因为最后一个节点的值为0可以将前面的节点都结算一遍
return ans;
}
};
(单调栈 + 2个哨兵节点)
最后还有一个问题,就是左边界的问题,因为栈中可能会没有左右边界,所以在使用sk.top()
需要特判一下,如果仿照上面的写法,在数组的开头使用一个“0“做哨兵节点,因为左边界高度最小也就只有”0”了,所以一定可以让“0”作为左边界。
注意:在数组的开头添加一个节点的成本是很高的需要移动所有的元素,所以尽量使用下标偏移。
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int len = heights.size();
if (len == 1) return heights[0];
heights.push_back(0);// 在最后添加一个哨兵
stack<int> sk;
int ans = 0;
sk.push(0);// 添加开头添加一个哨兵
for (int i = 1; i <= len + 1; i ++) {
while (!sk.empty() && sk.top() != 0
&& heights[i - 1] < heights[sk.top() - 1]) {// 注意所有的下标需要偏移一个单位
int top = sk.top() - 1;
sk.pop();
int w = i - sk.top() - 1;// 不用特判栈中的唯一一个元素
int h = heights[top];
ans = max(ans, w * h);
}
sk.push(i);
}
return ans;
}
};
另一种写法:
在数组开头插入“0”哨兵节点。
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int len = heights.size();
if (len == 1) return heights[0];
heights.insert(heights.begin(), 0);// 使用insert()添加开头添加一个哨兵
heights.push_back(0);// 在最后添加一个哨兵
stack<int> sk;
int ans = 0;
for (int i = 0; i <= len + 1; i ++) {
while (!sk.empty() && heights[i] < heights[sk.top()]) {
int top = sk.top();
sk.pop();
int w = i - sk.top() - 1;
int h = heights[top];
ans = max(ans, w * h);
}
sk.push(i);
}
return ans;
}
};
用栈实现队列
(双栈倒腾)
栈是先进后出的数据结构,队列是先进先出的数据结构。正是因为这两个数据结构性质上的这种对立性,也就使得他们之间可以互相转化(就像是负负得正一样)。
栈是先进后出,如果有两个栈就可以在叠加一层先进后出的效果,原来先进的元素现在会后出,而因为再次移动到另一个栈中,所以后进的元素就变成了先出。这样在两个栈中互相倒腾一下,原来先进的元素现在在另一个栈也是先进,原来后进的元素现在在另一个栈中也是后进的了,如此下来就可以看成是先进再出,后进后出了。
注意:
1.在编写代码的时候可以使用stack<int> sk_in
和stack<int> sk_out
作为输入栈和输出栈,这样可以清晰一点。
2.只有在输出栈为空的时候,才可以将所有输入栈中的东西放入输出栈中。不可以输出栈中还有元素的时候,就又将后面的元素放入输出栈中,这样就会打乱之前的元素顺序。
3.在使用peek()
接口的时候,可以使用pop()
的函数复用一下即可,这样可以省去冗余的代码。
push()
pop()
class MyQueue {
private:
stack<int> sk_in;
stack<int> sk_out;
public:
/** Initialize your data structure here. */
MyQueue() {}
/** Push element x to the back of queue. */
void push(int x) {
sk_in.push(x);
}
/** Removes the element from in front of queue and returns that element. */
int pop() {
if (sk_out.empty()) {
while (!sk_in.empty()) {
sk_out.push(sk_in.top());
sk_in.pop();
}
}
int top = sk_out.top();
sk_out.pop();
return top;
}
/** Get the front element. */
int peek() {
int top = pop();
sk_out.push(top);
return top;
}
/** Returns whether the queue is empty. */
bool empty() {
return sk_in.empty() && sk_out.empty();
}
};
用队列实现栈
(双队列实现)
队列的性质是先进先出,所以不能像用栈实现队列那样可以”负负得正“,而是只能“正正得正”。
所没有办法只能将队列中的元素循环一遍然后找出最后一个元素。但是有两种实现方式:
1.可以在push()
的时候就将刚进入的元素放在队列的开头,这样在pop()
和top()
的时候,就可以直接取用队列中的front()
即可。
在将队列的尾放在队列的开头的时候,可以使用一个辅助栈q1
,将q2
中的元素全部放在q1
中。这样就可以使得添加的元素都放在队列的开头,然后swap(q1, q2)
,这时就可以保证元素一定都在q2
当中,q1
只是一个辅助栈。
class MyStack {
private:
queue<int> q1;// q1队列永远为空只是一个辅助队列而已
queue<int> q2;
public:
/** Initialize your data structure here. */
MyStack() {}
/** Push element x onto stack. */
void push(int x) {
// 将元素放在q1中暂时保存,将q2中的元素放入q1中,然后交换两个队列
q1.push(x);
while (!q2.empty()) {
q1.push(q2.front());
q2.pop();
}
swap(q1, q2);
}
/** Removes the element on top of the stack and returns that element. */
int pop() {
int top = q2.front();
q2.pop();
return top;
}
/** Get the top element. */
int top() {
return q2.front();
}
/** Returns whether the stack is empty. */
bool empty() {
return q2.empty();
}
};
(循环利用队列)
可以每一次添加队列的头部元素,这样就可以使得队列中的元素循环添加,如此这样添加q.size() - 1
次就可以使得尾部的元素放在头部的位置,这样的做法可以省去q1
的空间。
class MyStack {
private:
queue<int> q;
public:
/** Initialize your data structure here. */
MyStack() {}
/** Push element x onto stack. */
void push(int x) {
// 将元素放在q1中暂时保存,将q2中的元素放入q1中,然后交换两个队列
q.push(x);
int size = q.size();
while (size > 1) {
q.push(q.front());
q.pop();
size --;
}
}
/** Removes the element on top of the stack and returns that element. */
int pop() {
int top = q.front();
q.pop();
return top;
}
/** Get the top element. */
int top() {
return q.front();
}
/** Returns whether the stack is empty. */
bool empty() {
return q.empty();
}
};
最小栈
(双栈实现 + 同步实现)
最小栈需要快速地找到当前元素中的最小值,那么只能使用一个stack<int> sk_min
来保存当前元素中的最小值,这样就可以实现在相同的所有元素中的同步地增加和删除最小值。
class MinStack {
private:
stack<int> sk;
stack<int> sk_min;
public:
/** initialize your data structure here. */
MinStack() {
}
void push(int val) {
sk.push(val);
// 如果栈为空或者当前值<=sk.min.top,就可以在min_sk中记录val为最小值
// 否则最小值还是sk_min.top()
if (sk_min.empty() || sk_min.top() >= val) {
sk_min.push(val);
} else {
sk_min.push(sk_min.top());
}
}
void pop() {
if (!sk.empty()) {
sk.pop();
sk_min.pop();
}
}
int top() {
return sk.top();
}
int getMin() {
return sk_min.top();
}
};
(记忆最小栈 + 保存最小值)
当然为了最小值不一定要大费周章的使用一个栈专门保存,使用一个变量min_val
也是可以的。
但是这就会导致一个问题,就就是min_val
的时效性。如果min_val
已经从栈中pop()
掉了,那么就不是当前元素中的最小值了。
解决:所以这就需要每一次在更新当前最小值min_val
的时候,将现在的min_val
保留一份在sk
中,这样如果在pop()
的时候,sk.top() == min_val
这是可以使用栈顶元素,也就是前面在栈中保存的剩下元素的最小值来更新当前的min_val
。
注意两个细节:
1.必须先比较val
和min_val
的大小,将当前的最小值存放在栈中;接着再将val
放入栈中。这是因为在pop()
的时候是先将当前的数字pop()
之后再决定是否使用栈顶的元素(栈顶的元素也不一定都是剩下栈中元素的最小值)
2.必须是val <= min_val
的时候将当前的最小值min_val
放入栈中,因为可能会有相同的最小值出现,所以如果出现了两次遇到最小值的情况就需要pop()
两次,所以当val
和min_val
相等的时候也需要将val
放入栈中。(例如, push(0), push(1), push(0), pop()
如果加上等号则栈中是[INT_MAX, 0, 1, 0, 0]
,如果不加上等号则栈中就是[INT_MAX, 0, 1, 0]
,当pop()
最后一个0的时候,则没有最小值可以取用)
class MinStack {
private:
stack<int> sk;
int min_val;
public:
/** initialize your data structure here. */
MinStack() {
min_val = INT_MAX;
}
void push(int val) {
if (val <= min_val) {// 先检查是否需要更新最小值
sk.push(min_val);
min_val = val;
}
sk.push(val);// 再将val放入栈中
}
void pop() {
int top = sk.top();
sk.pop();
if (top == min_val) {// 判断是否为当前的最小值,如果是需要更新最小值
min_val = sk.top();
sk.pop();
}
}
int top() {
return sk.top();
}
int getMin() {
return min_val;
}
};
设计循环队列
本题的难点在于有几种情况的判断条件是相同的,所以就会导致条件的不唯一性。
例如:
1.如果设置tail
和head
分贝指向最后一个元素和第一个元素,那么如何判空,如果head == tail
就是队列为空的话,但是当队列中没有元素或者有一个元素的时候,tail
和head
都是为指向同一个地方的。
2.如果设置tail
指向最后一个元素的后面一个位置,head
指向第一个元素的话,这时候解决了没有元素和有一个元素的条件重复。但是又有一个新的问题,就是如何判满呢?当队列中已经满了此时tail == head
,而tail == head
也是队列为空的条件,所以又矛盾了。
根据上面两种情况可以有两种方法:
(设置空置位)
第一种方法是为了解决第二种情况的:可以设置一个空置位,这样队列中总是至少有一个空的位置,这样的话可以使用(tail + 1) % capacity == head
来判断队列是否已满,此时的tail
就是在空置位上,而不会和head
重合,而引发冲突。
class MyCircularQueue {
private:
vector<int> cir_que;
int tail;
int head;
int capacity;
public:
MyCircularQueue(int k):tail(0), head(0), capacity(k + 1) {
cir_que.reserve(capacity);
}
bool enQueue(int value) {
if (isFull()) return false;
cir_que[tail] = value;
tail = (tail + 1) % capacity;
return true;
}
bool deQueue() {
if (isEmpty()) return false;
head = (head + 1) % capacity;
return true;
}
int Front() {
if (isEmpty()) return -1;
return cir_que[head];
}
int Rear() {
if (isEmpty()) return -1;
return cir_que[(tail - 1 + capacity) % capacity];
}
bool isEmpty() {
return head == tail;
}
bool isFull() {
return (tail + 1) % capacity == head;
}
};
(不设置空置位)不推荐
如果不设置空置位的话,就需要处理队列中只有一个元素和没有元素的情况。
可以初始化head
和tail
都为-1
。只有当head == -1
的时候才说明队列为空。但是在添加和删除只有一个元素的队列的时候需要特判一下,当队列中没有元素的时候,head = 0
,然后再让tail
往后一格,将val放入;当队列中只有一个元素并需要删除的时候,需要将head
和tail
置为-1
。
class MyCircularQueue {
private:
vector<int> cir_que;
int head;
int tail;
int capacity;
public:
MyCircularQueue(int k):head(-1), tail(-1), capacity(k) {
cir_que.reserve(capacity);
}
bool enQueue(int value) {
if (isFull()) return false;
if (head == -1) {// 添加第一个元的时候需要特判
head = 0;
}
tail = (tail + 1) % capacity;
cir_que[tail] = value;
return true;
}
bool deQueue() {
if (isEmpty()) return false;
if (head == tail) {// 只有一个元素的时候,需要特判
head = -1;
tail = -1;
} else {
head = (head + 1) % capacity;
}
return true;
}
int Front() {
if (isEmpty()) return -1;
else return cir_que[head];
}
int Rear() {
if (isEmpty()) return -1;
else return cir_que[tail];
}
bool isEmpty() {
return head == -1;// 这是本题设置-1的精髓,可以避免只有一个元素和没有元素的冲突
}
bool isFull() {
return (tail + 1) % capacity == head;
}
};
设计循环双端队列
(设置空置位)
class MyCircularDeque {
private:
vector<int> cir_que;
int head;
int tail;
int capacity;
public:
/** Initialize your data structure here. Set the size of the deque to be k. */
MyCircularDeque(int k):head(0), tail(0), capacity(k + 1) {
cir_que.reserve(capacity);
}
/** Adds an item at the front of Deque. Return true if the operation is successful. */
bool insertFront(int value) {
if (isFull()) return false;
head = (head - 1 + capacity) % capacity;
cir_que[head] = value;
return true;
}
/** Adds an item at the rear of Deque. Return true if the operation is successful. */
bool insertLast(int value) {
if (isFull()) return false;
cir_que[tail] = value;
tail = (tail + 1 + capacity) % capacity;
return true;
}
/** Deletes an item from the front of Deque. Return true if the operation is successful. */
bool deleteFront() {
if (isEmpty()) return false;
head = (head + 1) % capacity;
return true;
}
/** Deletes an item from the rear of Deque. Return true if the operation is successful. */
bool deleteLast() {
if (isEmpty()) return false;
tail = (tail - 1 + capacity) % capacity;
return true;
}
/** Get the front item from the deque. */
int getFront() {
if (isEmpty()) return -1;
else return cir_que[head];
}
/** Get the last item from the deque. */
int getRear() {
if (isEmpty()) return -1;
else return cir_que[(tail - 1 + capacity) % capacity];
}
/** Checks whether the circular deque is empty or not. */
bool isEmpty() {
return head == tail;
}
/** Checks whether the circular deque is full or not. */
bool isFull() {
return (tail + 1) % capacity == head;
}
};
(计数器法)
使用一个计数器也可以直接判断是否为空了,就不用再添加一个空置位给tail
来特判了。
class MyCircularDeque {
private:
vector<int> cir_que;
int head;
int tail;
int capacity;
int size;
public:
/** Initialize your data structure here. Set the size of the deque to be k. */
MyCircularDeque(int k): head(0), tail(0), capacity(k), size(0) {
cir_que.reserve(k);
}
/** Adds an item at the front of Deque. Return true if the operation is successful. */
bool insertFront(int value) {
if (isFull()) return false;
head = (head - 1 + capacity) % capacity;
cir_que[head] = value;
size ++;
return true;
}
/** Adds an item at the rear of Deque. Return true if the operation is successful. */
bool insertLast(int value) {
if (isFull()) return false;
cir_que[tail] = value;
tail = (tail + 1) % capacity;
size ++;
return true;
}
/** Deletes an item from the front of Deque. Return true if the operation is successful. */
bool deleteFront() {
if (isEmpty()) return false;
head = (head + 1) % capacity;
size --;
return true;
}
/** Deletes an item from the rear of Deque. Return true if the operation is successful. */
bool deleteLast() {
if (isEmpty()) return false;
tail = (tail - 1 + capacity) % capacity;
size --;
return true;
}
/** Get the front item from the deque. */
int getFront() {
if (isEmpty()) return -1;
else return cir_que[head];
}
/** Get the last item from the deque. */
int getRear() {
if (isEmpty()) return -1;
else return cir_que[(tail - 1 + capacity) % capacity];
}
/** Checks whether the circular deque is empty or not. */
bool isEmpty() {
return size == 0;
}
/** Checks whether the circular deque is full or not. */
bool isFull() {
return size == capacity;
}
};
总结:计数器法和设置空位法写法几乎一模一样,只是在判断是否队列为空时,计数器法使用的是size
判断,而设置空位法是使用tail
和head
的相对位置来判断的。但是大体上思路都是以head
为有效的第一个元素,tail
是有效的最后一个元素的下一个位置来设定的
滑动窗口的最大值
暴力方法就是枚举每一个窗口的左边界,然后从左边界开始枚举k个数找出最大值。
(双端队列维护单调队列)
现在想要做到在O(1)的时间内,找到窗口中的最大值
。是不是有一点像单调栈。单调栈就是维护了一个有单调性的栈,其中“保留法”就很像本题,所谓“保留法”就是:假设需要在O(1)时间内找到右边的第一个更大的值,可以从右往左遍历其中保留最大值,如果找到更大的数值就替代掉栈中的数字,这样每一个数字就可以在O(1)的时间被直接取用栈顶元素即可。
放在本题也是想找最大值,但是是窗口内的最大值。其实也是可以用这种思想的,在一个容器中维护最大值,如果发现后面后更大的数值,就从后面将数值删除(这个和单调栈的做法一样),这样就维护了一个单调递降的数据结构。和单调栈不同的是单调栈最终的最大或最小值都是在栈顶,而此时维护的最大值是在容器的最前面,所以这就需要一个可以从前面取出数值的数据结构,这里可以使用双端队列deque
或者双向链表list
,都可以从容器的前面取出数据。
还有一点要注意,因为窗口是滑动的,所以可能容器中的数据会失效(不在窗口中了),因此还需要判断是否窗口中有双端队列中的数值,这就提醒我们在维护一个单调队列的时候需要保存下标而不是数值本身。窗口的左边界为i - k + 1
,所以只要判断q.front() <= i - k
就可以知道数值是否已经出了窗口。而且因为是使用deque
来维护窗口中的数值的,所以可以使用pop_front()
将数值从窗口中删除,这也真正体现了为什么使用双端队列来维护单调队列
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int len = nums.size();
deque<int> q;
vector<int> ans;
for (int i = 0; i < len; i ++) {
// 维护一个单调队列
while (!q.empty() && nums[i] > nums[q.back()]) {
q.pop_back();
}
// 将下标加入已经维护好的队列
q.push_back(i);
// 检查队列中的元素的有效性
if (q.front() <= i - k) {
q.pop_front();
}
// 已经形成窗口的话,单调队列的头就是窗口中有效的最大值
if (i + 1 >= k) {
ans.push_back(nums[q.front()]);
}
}
return ans;
}
};
(优先队列)
想要知道一堆数中的最大值也可以使用大根堆,因为大根堆自动排序,所以堆顶元素就是最大值。使用大根堆就可以不用手动地去维护一个具有单调性的队列了,方便了不少。
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int len = nums.size();
vector<int> ans;
priority_queue<pair<int, int>> q;
for (int i = 0; i < len; i ++) {
// 将数值和对应下标放入大根堆中,堆会自动排序,就不用维护一个有序的队列了
q.push({nums[i], i});
// 将已经失效数值从窗口删除
while (!q.empty() && q.top().second <= i - k) {
q.pop();
}
// 判断是否已经形成滑动窗口
if (q.size() >= k) {
ans.push_back(q.top().first);
}
}
return ans;
}
};