1. 栈与单调栈在算法中的核心价值
栈(Stack)作为计算机科学中最基础的数据结构之一,其"后进先出"(LIFO)的特性在算法问题中有着不可替代的作用。在LeetCode Hot100这类经典算法题库中,栈相关的题目往往考察以下几个维度的能力:
- 对称性匹配:如有效的括号问题(LC20),利用栈结构完美匹配成对出现的符号
- 状态暂存:如字符串解码问题(LC394),需要栈来保存多层嵌套的字符串和重复次数
- 极值维护:最小栈问题(LC155)通过辅助栈实现O(1)时间复杂度的最小值查询
- 时序回溯:每日温度问题(LC739)需要找到后续第一个更高温度的天数
而单调栈(Monotonic Stack)作为栈的进阶应用,通过维护栈内元素的单调性,能够高效解决一类特殊的区间极值问题。柱状图最大矩形(LC84)就是单调栈的经典应用场景,其时间复杂度可以从暴力解的O(n²)优化到O(n)。
提示:理解单调栈的关键在于认识到——当新元素破坏栈的单调性时,弹出的元素其"下一个更大/更小元素"就是当前要入栈的元素。这个性质使得单调栈特别适合解决"下一个更大元素"类问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 有效的括号(LC20):栈的教科书式应用
2.1 问题本质与解法选择
给定一个只包含 '(', ')', '{', '}', '[' 和 ']' 的字符串,判断字符串是否有效。有效需满足:
- 左括号必须用相同类型的右括号闭合
- 左括号必须以正确的顺序闭合
这个问题是栈数据结构的"Hello World",因为:
- 括号的匹配具有最近相关性——最后出现的左括号需要最先匹配
- 未匹配的左括号需要暂存,直到遇到对应的右括号
cpp复制bool isValid(string s) {
stack<char> st;
for(char c : s) {
if(c == '(' || c == '{' || c == '[') {
st.push(c);
} else {
if(st.empty()) return false;
char top = st.top();
if((c == ')' && top != '(') ||
(c == '}' && top != '{') ||
(c == ']' && top != '[')) {
return false;
}
st.pop();
}
}
return st.empty();
}
2.2 边界条件与优化技巧
实际编码中容易忽略的边界情况:
- 单独出现的右括号(此时栈为空)
- 字符串遍历结束后栈不为空(有未匹配的左括号)
- 空字符串(应返回true)
一个优化技巧是使用哈希表存储括号对,减少条件判断的重复代码:
cpp复制unordered_map<char, char> pairs = {
{')', '('},
{'}', '{'},
{']', '['}
};
bool isValid(string s) {
stack<char> st;
for(char c : s) {
if(pairs.count(c)) {
if(st.empty() || st.top() != pairs[c])
return false;
st.pop();
} else {
st.push(c);
}
}
return st.empty();
}
3. 最小栈(LC155):空间换时间的经典案例
3.1 双栈法的设计思路
设计一个支持 push、pop、top 操作,并能在常数时间内检索到最小元素的栈。
最直观的暴力解法是在getMin时遍历整个栈,但这样时间复杂度是O(n)。要实现O(1)时间复杂度的getMin,必须牺牲空间来存储额外的信息。
cpp复制class MinStack {
stack<int> data_stack;
stack<int> min_stack;
public:
void push(int val) {
data_stack.push(val);
if(min_stack.empty() || val <= min_stack.top()) {
min_stack.push(val);
}
}
void pop() {
if(data_stack.top() == min_stack.top()) {
min_stack.pop();
}
data_stack.pop();
}
int top() {
return data_stack.top();
}
int getMin() {
return min_stack.top();
}
};
3.2 单栈法的优化版本
双栈法虽然直观,但存在冗余存储。更优的做法是只使用一个栈,同时存储元素与当前最小值的差值:
cpp复制class MinStack {
stack<long> st;
long min_val;
public:
void push(int val) {
if(st.empty()) {
st.push(0L);
min_val = val;
} else {
long diff = val - min_val;
st.push(diff);
if(diff < 0) min_val = val;
}
}
void pop() {
long diff = st.top();
st.pop();
if(diff < 0) {
min_val = min_val - diff; // 恢复前一个min_val
}
}
int top() {
long diff = st.top();
if(diff < 0) return min_val;
else return min_val + diff;
}
int getMin() {
return min_val;
}
};
这种方法利用了数学关系来压缩存储空间,但需要注意整数溢出问题(因此使用long类型)。在实际工程中,双栈法的可读性和稳定性通常更优。
4. 字符串解码(LC394):栈处理嵌套结构的典范
4.1 问题分析与递归解法
给定一个编码字符串,如 "3[a2[c]]",返回解码后的字符串 "accaccacc"。
这类嵌套结构的问题天然适合用栈来解决,因为:
- 需要暂存外层字符串片段
- 遇到数字时需要暂存当前字符串,先处理内层解码
- 遇到']'时需要将内层结果与外层字符串拼接
cpp复制string decodeString(string s) {
stack<pair<string, int>> st; // {current_string, repeat_times}
string current;
int num = 0;
for(char c : s) {
if(isdigit(c)) {
num = num * 10 + (c - '0');
} else if(c == '[') {
st.push({current, num});
current = "";
num = 0;
} else if(c == ']') {
auto [prev_str, times] = st.top();
st.pop();
string temp;
for(int i = 0; i < times; ++i) {
temp += current;
}
current = prev_str + temp;
} else {
current += c;
}
}
return current;
}
4.2 处理复杂嵌套的注意事项
在实际实现时,有几个关键点需要注意:
- 数字可能有多位(如"100[leetcode]"),需要累加处理
- 字符串拼接时避免频繁创建临时字符串(可以用string的reserve预分配空间)
- 栈中存储的是遇到"["时的上下文状态(当前字符串和重复次数)
一个常见的错误是在遇到"]"时没有正确处理字符串的拼接顺序。正确的做法是先重复内层字符串,再与外层字符串拼接,而不是反过来。
5. 每日温度(LC739):单调栈的入门例题
5.1 从暴力法到单调栈的优化
给定每日温度列表,返回需要等待多少天才能遇到更高温度的天数列表。
暴力解法是对于每个元素,向后遍历直到找到更大的温度,时间复杂度O(n²)。单调栈可以将时间复杂度优化到O(n)。
cpp复制vector<int> dailyTemperatures(vector<int>& temps) {
stack<int> st; // 存储下标而非温度值
vector<int> res(temps.size(), 0);
for(int i = 0; i < temps.size(); ++i) {
while(!st.empty() && temps[i] > temps[st.top()]) {
int prev = st.top();
st.pop();
res[prev] = i - prev;
}
st.push(i);
}
return res;
}
5.2 单调栈的工作机制解析
这个解法之所以高效,是因为:
- 栈中维护的是尚未找到"下一个更高温度"的天数索引
- 栈内温度保持单调递减(栈底到栈顶)
- 当遇到更高温度时,可以一次性处理栈中所有比当前温度低的元素
关键观察点:对于栈中的某个元素,当它被弹出时,当前元素就是它右边第一个比它大的元素。这个性质使得我们能够准确计算等待天数。
在实际编码中,存储下标而非温度值可以更方便地计算天数差,也避免了重复访问温度数组。
6. 柱状图中最大的矩形(LC84):单调栈的巅峰应用
6.1 暴力解法的局限性
给定非负整数数组表示柱状图高度,求能勾勒出的最大矩形面积。
最直观的解法是对于每个柱子,向左右扩展直到遇到更矮的柱子,计算此时的矩形面积。这种方法时间复杂度为O(n²),在n较大时(如1e5)会超时。
6.2 单调栈的优化思路
通过维护一个单调递增栈,可以在O(n)时间内解决问题:
cpp复制int largestRectangleArea(vector<int>& heights) {
stack<int> st;
heights.push_back(0); // 哨兵值,确保最后能清空栈
int max_area = 0;
for(int i = 0; i < heights.size(); ++i) {
while(!st.empty() && heights[i] < heights[st.top()]) {
int h = heights[st.top()];
st.pop();
int width = st.empty() ? i : (i - st.top() - 1);
max_area = max(max_area, h * width);
}
st.push(i);
}
return max_area;
}
6.3 关键步骤的深入解释
- 哨兵技巧:在数组末尾添加高度0,确保遍历结束时栈能被清空,避免单独处理剩余栈中元素
- 宽度计算:当弹出柱子时,其左边界是当前栈顶元素,右边界是正在处理的元素i
- 面积计算:高度为弹出柱子的高度,宽度为左右边界间的距离
这个问题的难点在于理解为什么单调栈能正确找到每个柱子的左右边界。关键在于栈的单调性保证了栈中每个柱子其下面的柱子就是它左边第一个比它矮的柱子,而当遇到比栈顶矮的柱子时,这个柱子就是栈顶柱子右边第一个比它矮的柱子。
在实际实现中,我建议在纸上画出柱状图,手动模拟单调栈的工作过程,这对理解算法非常有帮助。
