1. 栈与队列的经典应用场景解析
在算法学习过程中,栈(Stack)和队列(Queue)是两种最基础也最重要的数据结构。它们看似简单,但在解决实际问题时却有着不可替代的作用。今天我们就来深入探讨栈与队列在三个经典算法问题中的应用:逆波兰表达式求值、滑动窗口最大值和前K个高频元素。
1.1 数据结构特性回顾
栈是一种后进先出(LIFO)的数据结构,只允许在一端(称为栈顶)进行插入和删除操作。这种特性使得栈特别适合处理需要"回退"或"撤销"的场景,比如函数调用栈、括号匹配、表达式求值等。
队列则是一种先进先出(FIFO)的数据结构,允许在一端(队尾)插入元素,在另一端(队头)删除元素。队列常用于处理需要按顺序执行的场景,如消息队列、广度优先搜索、滑动窗口问题等。
双端队列(Deque)是队列的扩展版本,允许在两端进行插入和删除操作。这种灵活性使得它在某些特定场景下比普通队列更高效,比如我们今天要讨论的滑动窗口最大值问题。
2. 逆波兰表达式求值详解
2.1 逆波兰表达式简介
逆波兰表达式(Reverse Polish Notation, RPN),也称为后缀表达式,是一种不需要括号就能明确运算顺序的数学表达式表示方法。它的特点是运算符位于两个操作数之后,例如普通表达式"3 + 4"在逆波兰表达式中写作"3 4 +"。
这种表示法的最大优点是消除了运算符优先级和括号的歧义,使得表达式求值过程变得非常简单直观,特别适合用栈来实现。
2.2 算法实现解析
让我们仔细分析提供的C++实现代码:
cpp复制class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<long long> st;
for (int i = 0; i < tokens.size(); i++) {
if (tokens[i] == "+" || tokens[i] == "-" || tokens[i] == "*" || tokens[i] == "/") {
long long num1 = st.top();
st.pop();
long long num2 = st.top();
st.pop();
if (tokens[i] == "+") st.push(num2 + num1);
if (tokens[i] == "-") st.push(num2 - num1);
if (tokens[i] == "*") st.push(num2 * num1);
if (tokens[i] == "/") st.push(num2 / num1);
} else {
st.push(stoll(tokens[i]));
}
}
long long result = st.top();
return result;
}
};
算法步骤如下:
- 初始化一个空栈
- 遍历表达式中的每个token:
- 如果是数字,将其转换为long long类型后压入栈中
- 如果是运算符,从栈顶弹出两个数字,先弹出的是右操作数,后弹出的是左操作数
- 对这两个数字执行相应运算,将结果压回栈中
- 最后栈中剩下的唯一元素就是表达式的结果
注意:这里使用long long而不是int是为了防止大数运算时的溢出问题。在实际编程竞赛或面试中,数据类型的选择往往会影响程序的正确性。
2.3 字符串转换工具函数
C++标准库提供了一系列方便的字符串转换函数,这些函数都定义在
cpp复制stoi() // string to int
stol() // string to long
stoll() // string to long long
stoul() // string to unsigned long
stoull() // string to unsigned long long
stof() // string to float
stod() // string to double
stold() // string to long double
这些函数在算法实现中非常有用,特别是在处理输入数据时。需要注意的是,如果字符串无法转换为目标类型,这些函数会抛出invalid_argument或out_of_range异常。
2.4 实际应用中的注意事项
- 边界条件处理:空表达式、只有一个数字的表达式、连续多个运算符等情况都需要考虑
- 除法处理:注意整数除法的截断问题,以及除以零的错误处理
- 溢出问题:特别是乘法和加法运算容易导致溢出,使用更大范围的数据类型是常见解决方案
- 输入验证:确保输入的逆波兰表达式是有效的,运算符和操作数的数量匹配
3. 滑动窗口最大值问题深入剖析
3.1 问题描述与暴力解法
滑动窗口最大值问题要求给定一个数组nums和一个整数k,我们需要找到数组中每个大小为k的连续子数组的最大值。例如,对于nums = [1,3,-1,-3,5,3,6,7],k = 3,输出应该是[3,3,5,5,6,7]。
最直观的解法是对于每个窗口,遍历其中的所有元素找出最大值。这种方法的时间复杂度是O(nk),当n很大时效率很低。
3.2 单调队列优化解法
下面给出的解法使用了双端队列(deque)来优化时间复杂度到O(n):
cpp复制class Solution {
public:
deque<int> test;
void pop(int t){
if(!test.empty()&&t==test.front()){
test.pop_front();
}
}
void push(int t){
while(!test.empty()&&test.back()<t){
test.pop_back();
}
test.push_back(t);
}
int front(){
return test.front();
}
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
vector<int> ans;
for(int i=0;i<k;i++){
push(nums[i]);
}
ans.push_back(test.front());
printf("%d\n",test.front());
for(int i=k;i<nums.size();i++){
pop(nums[i-k]);
push(nums[i]);
ans.push_back(front());
}
return ans;
}
};
这个解法的核心思想是维护一个单调递减的双端队列,队列中存储的是数组元素的索引(实际代码中存储的是值,但通常存储索引更安全)。队列中的元素按照从大到小的顺序排列,且它们的索引也是递增的。
3.3 算法步骤详解
- 初始化一个空的双端队列
- 处理前k个元素,构建初始的单调队列
- 记录第一个窗口的最大值(队列头部元素)
- 滑动窗口:
- 移除离开窗口的元素(如果它是当前最大值)
- 添加新元素到队列中,保持单调性
- 记录新窗口的最大值
- 返回所有窗口的最大值集合
提示:在实际实现中,队列中存储元素的索引而不是值会更安全,这样可以避免值相同但位置不同的元素被错误处理。
3.4 为什么使用双端队列
普通队列无法高效地同时支持从两端删除元素的操作。在这个算法中,我们需要:
- 从头部删除离开窗口的旧最大值
- 从尾部删除比新元素小的值以保持单调性
这正是双端队列的典型应用场景。双端队列的push和pop操作在两端都可以在O(1)时间内完成,从而保证了整体算法的高效性。
3.5 复杂度分析
- 时间复杂度:O(n),每个元素最多被压入和弹出队列各一次
- 空间复杂度:O(k),队列中最多存储k个元素
4. 前K个高频元素问题解析
4.1 问题描述
给定一个非空的整数数组,返回其中出现频率前k高的元素。例如,对于nums = [1,1,1,2,2,3], k = 2,输出应该是[1,2]。
4.2 解题思路
虽然原文提到"代码基础太差了,先跳了",但这个问题的解法非常经典,值得深入探讨。解决这个问题通常需要以下几个步骤:
- 统计每个元素的出现频率(使用哈希表)
- 根据频率对元素进行排序
- 取出前k个高频元素
4.3 最优解法:最小堆
更高效的解法是使用最小堆(优先队列):
- 先用哈希表统计每个元素的频率
- 维护一个大小为k的最小堆,堆顶是当前k个元素中频率最小的
- 遍历哈希表,将元素加入堆中,当堆大小超过k时,弹出堆顶元素
- 最后堆中剩下的就是前k个高频元素
这种方法的时间复杂度是O(n log k),比完全排序的O(n log n)更优,特别是当k远小于n时。
4.4 C++实现示例
cpp复制class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> freq;
for (int num : nums) {
freq[num]++;
}
auto cmp = [&freq](int a, int b) {
return freq[a] > freq[b];
};
priority_queue<int, vector<int>, decltype(cmp)> heap(cmp);
for (auto& pair : freq) {
heap.push(pair.first);
if (heap.size() > k) {
heap.pop();
}
}
vector<int> result;
while (!heap.empty()) {
result.push_back(heap.top());
heap.pop();
}
return vector<int>(result.rbegin(), result.rend());
}
};
4.5 实际应用中的变种
在实际工程中,类似的问题有很多变种:
- 统计日志中出现最频繁的IP地址
- 找出用户最常点击的商品
- 识别系统中最耗资源的操作
理解这个问题的解法可以帮助我们解决一大类"Top K"问题。
5. 数据结构选择与性能考量
5.1 queue与deque的比较
queue和deque都是C++标准库中的容器适配器,但有着不同的特性和用途:
| 特性 | queue | deque |
|---|---|---|
| 插入位置 | 仅队尾 | 队首和队尾 |
| 删除位置 | 仅队首 | 队首和队尾 |
| 随机访问 | 不支持 | 支持 |
| 内存分配 | 通常基于deque | 动态分段连续 |
| 典型应用 | BFS算法 | 滑动窗口问题 |
在滑动窗口最大值问题中,我们需要频繁地从两端操作队列,因此deque是更合适的选择。
5.2 public和private访问控制
在C++类定义中,public和private关键字用于控制成员的访问权限:
- public成员:可以被任何代码访问
- private成员:只能被类自身的成员函数访问
良好的类设计应该遵循封装原则,将实现细节设为private,只暴露必要的接口为public。在算法实现中,我们通常不需要严格的封装,因此很多解法类将所有成员都设为public。
5.3 容器选择策略
选择合适的数据结构对算法效率有决定性影响。以下是一些常见场景的容器选择建议:
- 需要快速查找:unordered_map/unordered_set(哈希表)
- 需要有序存储:map/set(红黑树)
- 需要频繁在两端操作:deque
- 需要后进先出:stack
- 需要先进先出:queue
- 需要随机访问:vector
理解各种容器的底层实现和复杂度特性,可以帮助我们在解决问题时做出更明智的选择。
6. 算法学习的方法论
6.1 从理解到实现
算法学习通常经历几个阶段:
- 理解问题:明确输入、输出和约束条件
- 设计算法:构思解决方案,考虑时间和空间复杂度
- 实现代码:将思路转化为具体代码
- 调试优化:处理边界条件,优化性能
在"代码随想录"这样的训练营中,系统化的学习和刻意练习是提高算法能力的关键。
6.2 调试技巧
当算法实现出现问题时,可以尝试以下调试方法:
- 小数据测试:用简单的测试用例手动验证
- 打印中间结果:在关键步骤输出变量状态
- 边界检查:特别关注空输入、极端值等情况
- 逐步执行:使用调试器一步步跟踪程序执行
6.3 学习资源推荐
- 《算法导论》:经典算法教材,理论深入
- LeetCode:在线编程练习平台
- "代码随想录":系统化的算法学习路线
- GeeksforGeeks:丰富的算法实现和解释
持续学习和实践是掌握算法的不二法门。建议每天解决1-2个算法问题,并定期复习已经学过的内容。