1. 逆波兰表达式求值详解
1.1 什么是逆波兰表达式
逆波兰表达式(Reverse Polish Notation,RPN),也称为后缀表达式,是一种数学表达式的表示方法。与我们日常使用的中缀表达式(如3+4)不同,逆波兰表达式的操作符位于操作数之后。例如,中缀表达式"3 + 4"对应的逆波兰表达式是"3 4 +"。
这种表示法的最大优点是无需使用括号来指定运算顺序,所有运算顺序都由操作符的位置决定。这使得计算机能够更容易地解析和计算表达式,因为不需要处理复杂的运算符优先级和括号嵌套问题。
1.2 逆波兰表达式的计算原理
计算逆波兰表达式的核心思想是使用栈这种数据结构。具体步骤如下:
- 初始化一个空栈
- 从左到右扫描表达式中的每个元素
- 如果当前元素是数字,将其压入栈中
- 如果当前元素是运算符:
- 从栈中弹出两个操作数(注意顺序:先弹出的是右操作数,后弹出的是左操作数)
- 对这两个操作数执行当前运算符的运算
- 将运算结果压回栈中
- 重复步骤2-4直到表达式结束
- 最后栈中剩下的唯一数字就是整个表达式的计算结果
这种方法的巧妙之处在于它自然地处理了运算符的优先级和结合性问题。例如,中缀表达式"3 + 4 * 5"对应的逆波兰表达式是"3 4 5 * +",计算时会先进行乘法运算,再进行加法运算,完全符合数学运算规则。
1.3 代码实现与优化
在实现逆波兰表达式求值时,我们可以使用数组来模拟栈的操作,这通常比使用标准库的栈容器更高效。以下是优化后的实现思路:
cpp复制class Solution {
public:
int evalRPN(vector<string>& tokens) {
vector<int> stack;
for (const auto& token : tokens) {
if (token.size() > 1 || isdigit(token[0])) {
stack.push_back(stoi(token));
} else {
int b = stack.back(); stack.pop_back();
int a = stack.back(); stack.pop_back();
switch (token[0]) {
case '+': stack.push_back(a + b); break;
case '-': stack.push_back(a - b); break;
case '*': stack.push_back(a * b); break;
case '/': stack.push_back(a / b); break;
}
}
}
return stack.back();
}
};
这个实现有几个关键点需要注意:
- 数字判断:由于可能存在负数(如"-11"),不能仅检查第一个字符是否为数字,还要考虑字符串长度
- 操作数顺序:先弹出的是右操作数,后弹出的是左操作数,这对减法和除法特别重要
- 除法处理:题目要求向零截断,C++的整数除法正好满足这个要求
1.4 边界条件与异常处理
在实际应用中,我们需要考虑一些边界情况和可能的异常:
- 空输入:虽然题目保证输入有效,但实际应用中应该检查
- 除零错误:题目保证不含除零运算,但实际代码中应该处理
- 栈操作:确保在弹出操作数前栈中有足够元素
- 整数溢出:题目保证结果在32位整数范围内,但实际应用中需要检查
提示:在面试中,即使题目保证输入有效,讨论这些边界情况也能展示你的全面思考能力。
1.5 时间复杂度分析
该算法的时间复杂度是O(n),其中n是tokens数组的长度。因为我们只需要遍历一次tokens数组,每个元素只处理一次。栈操作(push和pop)的时间复杂度都是O(1)。
空间复杂度也是O(n),最坏情况下(全是数字没有运算符),栈中需要存储(n+1)/2个元素(因为每两个数字和一个运算符可以合并为一个数字)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 滑动窗口最大值问题解析
2.1 问题描述与直观解法
滑动窗口最大值问题要求我们给定一个整数数组nums和一个整数k(窗口大小),返回窗口每次滑动时的最大值。例如,对于nums = [1,3,-1,-3,5,3,6,7],k = 3,输出应该是[3,3,5,5,6,7]。
最直观的解法是暴力法:对于每个窗口,遍历其中的所有元素找出最大值。这种方法的时间复杂度是O(nk),当n和k都很大时效率很低。
2.2 优先队列优化解法
我们可以使用优先队列(堆)来优化这个问题的解法。具体思路是:
- 维护一个大根堆,存储元素值及其在数组中的索引
- 初始时,将前k个元素加入堆中
- 然后对于每个新元素:
- 将其加入堆中
- 检查堆顶元素是否在当前窗口内(索引 >= 当前窗口左边界)
- 如果不在,则弹出堆顶元素,直到堆顶元素在当前窗口内
- 当前窗口的最大值就是堆顶元素
cpp复制class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
priority_queue<pair<int, int>> heap; // 最大堆,存储(值, 索引)
vector<int> result;
// 初始化第一个窗口
for (int i = 0; i < k; ++i) {
heap.emplace(nums[i], i);
}
result.push_back(heap.top().first);
// 处理后续窗口
for (int i = k; i < nums.size(); ++i) {
heap.emplace(nums[i], i);
// 移除不在当前窗口的最大值
while (heap.top().second <= i - k) {
heap.pop();
}
result.push_back(heap.top().first);
}
return result;
}
};
2.3 时间复杂度分析
使用优先队列的解法在最坏情况下的时间复杂度是O(n log n),因为每个元素最多被插入和弹出堆一次,堆操作的时间复杂度是O(log n)。
空间复杂度是O(n),因为最坏情况下堆中可能存储所有元素。
2.4 单调队列优化解法
更优的解法是使用单调队列,可以将时间复杂度降低到O(n)。单调队列的核心思想是维护一个递减的队列,队列中存储的是元素的索引,且对应的值是递减的。
cpp复制class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> dq; // 存储索引,对应值单调递减
vector<int> result;
for (int i = 0; i < nums.size(); ++i) {
// 移除队列中不在窗口内的元素
while (!dq.empty() && dq.front() <= i - k) {
dq.pop_front();
}
// 移除队列中比当前元素小的元素,保持单调递减
while (!dq.empty() && nums[dq.back()] < nums[i]) {
dq.pop_back();
}
dq.push_back(i);
// 当窗口形成时,记录最大值
if (i >= k - 1) {
result.push_back(nums[dq.front()]);
}
}
return result;
}
};
这种解法之所以更高效,是因为每个元素最多被加入和弹出队列一次,所有操作的时间复杂度都是O(1)的平摊时间,因此总时间复杂度是O(n)。
3. 前K个高频元素问题详解
3.1 问题描述与基本思路
前K个高频元素问题要求我们从一个整数数组中找出出现频率最高的k个元素。例如,对于nums = [1,1,1,2,2,3],k = 2,输出应该是[1,2]。
解决这个问题的基本思路是:
- 统计每个元素的出现频率(使用哈希表)
- 根据频率找出前k高的元素(可以使用堆排序)
3.2 哈希表与堆的结合使用
我们可以使用哈希表来统计频率,然后使用最小堆来找出前k个高频元素。使用最小堆而不是最大堆的原因是我们可以维护堆的大小为k,当新元素的频率大于堆顶元素的频率时,替换堆顶元素。这样最后堆中剩下的就是前k个高频元素。
cpp复制class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
// 统计频率
unordered_map<int, int> freqMap;
for (int num : nums) {
freqMap[num]++;
}
// 定义最小堆的比较函数
auto cmp = [](const pair<int, int>& a, const pair<int, int>& b) {
return a.second > b.second;
};
// 创建最小堆
priority_queue<pair<int, int>, vector<pair<int, int>>, decltype(cmp)> minHeap(cmp);
// 维护大小为k的堆
for (const auto& entry : freqMap) {
if (minHeap.size() < k) {
minHeap.push(entry);
} else if (entry.second > minHeap.top().second) {
minHeap.pop();
minHeap.push(entry);
}
}
// 提取结果
vector<int> result;
while (!minHeap.empty()) {
result.push_back(minHeap.top().first);
minHeap.pop();
}
return result;
}
};
3.3 时间复杂度分析
- 统计频率:O(n),需要遍历整个数组
- 建堆:最坏情况下是O(m log k),其中m是不同元素的数量
- 提取结果:O(k log k)
总的时间复杂度是O(n + m log k),当k远小于m时,这比完全排序的O(m log m)更高效。
空间复杂度是O(m + k),用于存储哈希表和堆。
3.4 其他解法:快速选择算法
另一种高效的解法是使用快速选择算法(Quickselect),它基于快速排序的思想,可以在平均O(n)时间内找到前k个高频元素。
cpp复制class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
// 统计频率
unordered_map<int, int> freqMap;
for (int num : nums) {
freqMap[num]++;
}
// 将频率和对应的元素存入vector
vector<pair<int, int>> elements;
for (const auto& entry : freqMap) {
elements.emplace_back(entry.second, entry.first);
}
// 使用快速选择找到前k大的元素
nth_element(elements.begin(), elements.begin() + k, elements.end(),
[](const pair<int, int>& a, const pair<int, int>& b) {
return a.first > b.first;
});
// 提取结果
vector<int> result;
for (int i = 0; i < k; ++i) {
result.push_back(elements[i].second);
}
return result;
}
};
快速选择算法的平均时间复杂度是O(n),最坏情况下是O(n^2),但通过合理选择pivot可以避免最坏情况。这种解法在实际应用中通常比堆方法更快,特别是当k较大时。
4. 栈与队列的应用技巧总结
4.1 栈的常见应用场景
- 表达式求值(如逆波兰表达式)
- 括号匹配检查
- 函数调用栈(递归转迭代)
- 深度优先搜索(DFS)
- 浏览器的前进后退功能
- 撤销操作(Undo)功能
4.2 队列的常见应用场景
- 广度优先搜索(BFS)
- 缓存实现(如FIFO缓存)
- 任务调度
- 消息队列
- 滑动窗口问题
- 打印机任务队列
4.3 选择栈还是队列?
选择使用栈还是队列取决于问题的性质:
- 当需要"后进先出"的处理顺序时,使用栈
- 当需要"先进先出"的处理顺序时,使用队列
- 某些问题可能需要结合使用两者,如使用队列实现栈或使用栈实现队列
4.4 性能优化技巧
- 使用数组模拟栈/队列:通常比标准库容器更高效
- 预先分配足够空间:避免动态扩容的开销
- 双栈法:某些问题可以用两个栈来优化
- 单调栈/队列:专门解决一类极值问题
- 循环队列:节省空间,避免数据搬移
4.5 常见错误与调试技巧
- 栈/队列为空时进行pop操作:始终检查empty()
- 混淆操作顺序:特别是在处理表达式时注意操作数顺序
- 内存问题:数组模拟时注意索引边界
- 优先级混淆:特别是在自定义比较函数时
- 并发访问问题:多线程环境下需要同步机制
提示:在解决栈和队列相关问题时,画图是最有效的调试方法之一。将每一步的栈/队列状态画出来,可以清晰看到问题所在。
