数据结构:栈和队列
栈和队列基础知识
栈和队列都是受限的数据结构;栈只允许在栈顶进行元素的插入和删除,只能访问最近添加元素,没有随机访问元素的能力,基于后进先出LIFO的原则;队列只允许在队尾进行元素插入,在队头进行元素删除,只能访问最远添加的元素,没有随机访问元素的能力,基于先进先出FIFO的原则;
栈和队列是特殊的数据结构,在实现上和之前学的容器类vector
有什么区别呢?
vector
是容器,栈stack
和队列queue
不是容器,而是容器适配器;stack
和queue
可以用vector
作为底层结构;(容器适配器不是容器,容器适配器的底层结构是容器)vector
明显更加不受限制,vector
可以随机访问,也提供了push_back
和pop_back
这些成员函数;而stack
和queue
只提供了有限功能的成员函数,比如push
、pop
、top
等,不能随意插入和删除元素,也不能随意访问任意位置元素;(如果底层容器选择vector
,stack
就可以看作受限的vector
)- 即使以
vector
容器为底层实现的stack
,也不可以使用vector
的大部分强大的接口,而只能使用受限的stack
的成员函数;所以从外表看,stack
是一个独立的数据结构,有独立且特殊的成员函数,并且只能通过这些特定的成员函数操作数据,和vecter
无关; vector
等容器类有迭代器,而stack
没有迭代器,要访问栈中所有元素只能将一个个元素从栈顶弹出栈;stack
只关注栈顶元素,queue
只关注队头队尾元素;
容器和容器适配器:
- 容器是用来存储一组元素的数据结构,向量
vector
、列表list
、双端队列deque
等都是容器,而栈stack
、队列queue
、优先队列priority_queue
是容器适配器而不是容器,这些容器适配器实际上是将现有的序列式容器的基础上封装出不同的接口,以提供特定的功能; - 容器适配器的底层容器是可拔插的,也就是既可以用
vector
实现stack
,也可以用list
实现stack
; - 容器适配器本身并不存储元素,而是依赖于底层的容器来实际存储数据;
- 容器适配器在外表上看有自己的成员函数,看上去就像是容器,可是它没办法存储元素,必须依靠底层容器来存储元素。
C++中的stack
和queue
实现:(以stack
为例)
指定底层容器:(默认底层容器是deque
)
std::stack<int, std::vector<int>> myStack; // 使用 vector 作为底层容器
std::stack<int, std::list<int>> myStack; // 使用 list 作为底层容器
std::stack<int, std::deque<int>> myStack; // 使用 deque 作为底层容器
std::stack<int> myStack; // 使用默认底层容器
不同底层容器实现的stack
的差异:stack
并不存储元素,存储元素是底层容器实现的;所以不同容器在某些操作上可能有不同的时间复杂度;在存储元素时内存分配方式也不同,比如vector
的扩容机制;但是在stack
的操作上,差异不大;(底层有差异,表现出来差异不大)
使用vector
实现简单stack
容器适配器:
#include <vector>
template <typename T>
class MyStack {
private: // 私有,不能直接访问,必须通过public提供的接口,这样就能实现受限访问
std::vector<T> data; // 使用 vector 作为底层容器
public: // 公开必要接口,实现受限访问,避免数据泄露
void push(const T& value) {
data.push_back(value); // 调用底层容器的 push_back() 添加元素
}
void pop() {
data.pop_back(); // 调用底层容器的 pop_back() 移除元素
}
T& top() {
return data.back(); // 返回底层容器的最后一个元素
}
bool empty() const {
return data.empty(); // 检查底层容器是否为空
}
size_t size() const {
return data.size(); // 返回底层容器的大小
}
};
虽然底层容器是vector
,但是public
中只公开了部分接口可以操作vector
,所以stack
此时就是受限访问的vector
;修改底层容器,对于使用stack
时的接口没有改变,但是底层实现可能发生变化。
栈里面的元素在内存中是连续分布的么?
- 陷阱1:栈是容器适配器,底层容器使用不同的容器,导致栈内数据在内存中不一定是连续分布的。
- 陷阱2:缺省情况下,默认底层容器是
deque
,那么deque
在内存中的数据分布是什么样的呢? 答案是:不连续的。
栈和队列题目
用栈实现队列:用两个栈模拟出队、入队、获取队顶元素等;
class MyQueue {
private:
stack<int> stIn; //输入栈;
stack<int> stOut; //输出栈;
public:
MyQueue() {
}
void push(int x) {
stIn.push(x); //入队直接压栈到输入栈;
}
int pop() {
if(stOut.empty()) { //如果输出栈为空,则输入栈所有进入输出栈
while(!stIn.empty()){
stOut.push(stIn.top());
stIn.pop();
}
}
int result = stOut.top();
stOut.pop();
return result;
}
int peek() {
int res = this->pop(); //直接使用定义的pop函数
stOut.push(res); //弹出栈再压回去;
return res;
}
bool empty() {
//输入栈和输出栈都为空时,队列为空;
return stIn.empty() && stOut.empty();
}
};
我们之前说C++中std::queue
队列是容器适配器类,可以选择不同的容器作为底层;上面代码中,我们自己实现了一个队列,具体是用两个栈实现的队列;我们实现的队列 MyQueue
是由栈实现的,而栈的底层是由 vector
实现的,看起来像不像是套娃?
之前我们用vector
实现一个简单的stack
类时提到,底层容器vector
在private
中,而public
中只提供了栈的接口,所以我们只能用栈的操作方式来操作stack
类,而不能用vector
的操作方式来修改stack
内容。这里用两个stack
实现队列时,也应该将两个stack
定义在private
中,这样我们不能通过操作栈来修改Myqueue
中元素,只能通过public
中提供的队列的操作方式来操作Myqueue
;
用队列模拟栈:用两个队列实现栈的基本操作;
class MyStack {
private:
queue<int> que1;
queue<int> que2;
public:
MyStack() {
}
void push(int x) {
//模拟入栈就是直接入队即可;
que1.push(x);
}
int pop() {
//模拟出栈:将队列中清空至只剩一个最后入队的元素后,出队;
int size = que1.size();
size--;
while(size--) {
que2.push(que1.front());
que1.pop();
}
int result = que1.front();
que1.pop();
//恢复现场,que1空了,que2成了之前的备份,que2要变成que1;
que1 = que2;
//que2可以清空了;
while(!que2.empty()) {
que2.pop();
}
return result;
}
int top() {
//利用之前写好的pop()实现;或者直接返回队尾元素的值;
return que1.back();
}
bool empty() {
return que1.empty();
}
};
有效的括号:括号匹配,栈的典型应用;
class Solution {
public:
bool isValid(string s) {
if(s.size() % 2 != 0) return false; //提前终止;
stack<char> st;
for(int i = 0; i < s.size(); i++) {
if (s[i] == '(') st.push(')'); //找到左括号,右括号反而入栈,降低匹配到的判断逻辑;
else if (s[i] == '{') st.push('}');
else if (s[i] == '[') st.push(']');
else if (st.empty() || st.top() != s[i]) return false;
else st.pop(); //匹配成功;
}
return st.empty();
}
};
栈在编译、操作系统和其他领域中有着广泛的应用,以下是一些常见的应用场景:
- 编译器对于括号的处理:编译器在编译代码时需要检查代码中的括号是否匹配,这通常通过栈的数据结构来实现。编译器会遍历代码中的括号,遇到左括号则将其压入栈中,遇到右括号则将栈顶元素弹出并检查是否与对应的左括号匹配,如果匹配则继续,否则报错。
- 操作系统中文件目录结构:在操作系统中,文件系统通常采用树形结构来组织文件目录,每个目录下可以包含子目录或文件。操作系统在管理文件目录结构时也会使用到栈的数据结构,比如在实现路径解析、递归遍历文件目录等过程中。
- 递归中的递归栈:在递归函数中,每一次函数调用都会将当前函数的局部变量、参数等信息保存在栈帧中,同时在调用新的函数时会将新函数的信息推入栈中,形成调用栈。这个调用栈实际上就是栈的应用之一。如果递归层级很深,栈的大小可能会有限制,导致栈溢出。
递归过程中使用的递归栈可能溢出,所以在工程项目中,减少使用递归很有必要;
逆波兰表达式求值:后缀表达式求值;
/*
逆波兰表达式求值;遇到数字入栈,遇到符号取出栈顶两个数字运算后入栈;
没有括号,就没有歧义;
*/
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])); //字符要转换为数字;
//stoll是将字符串转换为整型的函数;
}
}
int result = st.top();
st.pop();
return result;
}
};
中缀表达式转换为后缀表达式的手算方法:
例如中缀表达式"(a+b)*c+d"
,首先加小括号,变为"(((a+b)*c)+d)"
,其次将符号放到最近的右括号的有右边"(((ab)+c)*d)+"
,最后去除所有括号"ab+c*d+"
,即从中缀表达式得到后缀表达式;
计算后缀表达式的值:用栈,遇到数字入栈,遇到符号从栈中取出两个数字计算,计算结果放回栈中;
中缀表达式转换为后缀表达式(也可以利用栈实现):
- 创建一个操作数栈和一个运算符栈。
- 从左到右遍历中缀表达式的每个元素:
- 如果是操作数,直接输出到后缀表达式。
- 如果是左括号,将其压入运算符栈。
- 如果是右括号,将运算符栈顶的元素逐个弹出并输出到后缀表达式,直到遇到对应的左括号。
- 如果是运算符:
- 当前运算符优先级高于栈顶运算符,直接入栈。
- 否则,将栈顶运算符弹出并输出到后缀表达式,直到栈顶运算符优先级小于当前运算符或栈为空。
- 遍历完整个中缀表达式后,将运算符栈中的所有元素依次弹出并输出到后缀表达式。
滑动窗口的最大值:单调队列的应用;
给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
可以使用现成的数据结构multiset
:(mutiset
红黑树实现的有序集合)
思路:
使用队列实现滑动窗口。每次窗口移动的时候,调用que.pop()(滑动窗口中移除元素的数值)
,que.push(滑动窗口添加元素的数值)
,然后que.front()
就返回我们要的最大值。
怎么找最大值?难道队首元素就是最大值?
如果排序,则没办法保持元素的相对顺序,滑动窗口滑动后,哪个元素应该滑出去?(最大值元素不一定是滑动窗口最左边的元素)
如果不排序,怎么知道最大值,怎么把最大值返回?
之所以纠结于排序还是不排序,是因为我们陷入了思维误区,为什么一定要全部有序?为什么要维护窗口里的所有元素?对窗口里所有元素排序当然可以获取到最大元素,可是获取最大元素未必要对窗口里所有元素排序;
不维护所有元素,只维护可能是最大值的元素;
单调队列就可以完成这一功能;单调队列不是对窗口里的所有元素排序,而是选择窗口里的部分元素,保持这些元素的相对位置,并且保证选择的这些元素的值保持单调递增或者单调递减;
窗口里的元素: {2, 3, 5, 1, 4}
对窗口里的所有元素进行排序:{5, 4, 3, 2, 1}
单调队列(单减):{5, 4}
单调队列(单增):{2, 3, 5}
以递减的单调队列为例,滑动窗口最大值元素就是单调队列的第一个元素;
如何维护一个单调队列?见下表(模拟滑动窗口使用单调队列实现的过程):
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
单调队列:(模拟过程)
[1] // 队空,直接插入
[3] // 插入元素3,3大于1,一旦插入队列就不是单调递减队列了,所以将1弹出后插入3
[3 -1] // 插入-1,-1小于3,不改变单调性,此时i指向2,第一个窗口已经扫描完成,获取队头元素3为最大值(注意只是获取,并不弹出,自此后面每次都要输出最大值)
[3 -1 -3] // 插入-3,不改变单调性;获取队首元素3作为最大值
[5] // 插入5,首先由于滑动窗口长度为3,所以队列最大也为3,队首元素先直接出队;然后5和-1比,5大于-1,则说明队中所有元素均小于5,队中元素全部弹出;5入队,然后输出队首元素5作为最大值
[5 3] // 插入3,最大值为5
[6] // 弹出5,弹出3,插入6,最大值为6
[7] // 弹出6,插入7,最大值为7
单调队列构建的规则:
- 队列长度最长为3,如果队长已经为3,而且此时有新元素要插入,弹出队首元素;
- 插入队尾元素时,要先和队首元素比较,如果队首元素大于插入元素,则直接插入;如果队首元素小于插入元素,则弹出队列中所有元素后插入;(如果队首元素小于插入元素,则说明队列中所有元素都小于插入元素,无需继续比较,直接全部弹出)
/*
找一个区间内的最值——使用单调队列(单调队列指单调递减或递增的队列)
单调队列的push和pop原则:
1. pop:如果队首元素等于窗口移除元素,则队首弹出;
2. push:如果窗口增加元素大于队尾元素,则将队尾元素弹出,继续判断
直到队尾元素小于等于窗口增加元素;(最大值在队头)
要注意,单调队列元素个数小于等于窗口大小,单调队列只保存较大的几个元素,
也只维护较大的元素;
最大值:队头元素是每轮的最大值;
注意:队列可以pop_front和pop_back;
*/
class Solution {
private: //自己重写单调队列的功能;
class MyQueue {
public:
deque<int> que;
void pop(int value) {
//pop:如果队首元素等于窗口移除元素value,则队首弹出;
if (!que.empty() && que.front() == value) {
que.pop_front(); //弹出队首元素;
}
}
void push(int value) {
//push:如果窗口增加元素大于队尾元素,则将队尾元素弹出,继续判断直到队尾元素小于等于窗口增加元素;
while (!que.empty() && value > que.back()) {
que.pop_back();
}
que.push_back(value);
}
int front() {
return que.front();
}
};
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
MyQueue que;
vector<int> result;
for (int i = 0; i < k; i++) { //开始的窗口载入
que.push(nums[i]);
}
result.push_back(que.front()); //最大值在队头,将队头元素添加到result;
for (int i = k; i < nums.size(); i++) {
que.pop(nums[i-k]); //移除滑动窗口最前面元素nums[i-k];
que.push(nums[i]); //增加滑动窗口最后的元素;
result.push_back(que.front()); //每轮滑动后记录最大值;
}
return result;
}
};
/*
单调队列的使用:
deque重写push和pop方法,使队列元素有序;
deque可以两头进出,区分于stack;push_front(), push_back();
pop_front(); pop_back();,所以更灵活;
本题中的单调队列写法并不是一切单调队列的写法;
*/
单调队列的实现,nums
中的每个元素最多也就被 push_back()
和 pop_back()
各一次,没有任何多余操作,所以整体的复杂度还是
O
(
n
)
O(n)
O(n)。(如果使用暴力算法,则时间复杂度为
O
(
n
∗
k
)
O(n*k)
O(n∗k),
k
k
k为窗口大小)
为什么使用单调队列?因为我们遇到了问题中,如果排序则没办法维护元素的相对顺序,如果不排序,则没办法获取最大最小值;而单调队列,即获取了最大最小值,又可以不改变较大值或者较小值的相对顺序;
单调序列的常见应用:
- 滑动窗口的最大值/最小值问题;(滑动窗口每次移动时,维护队列的单调性;可以在常数时间内获取到当前窗口的最值)
- 股票价格涨跌模拟问题;(获取最近一段时间内股票涨跌的峰值,本质和滑动窗口求最值一致,是滑动窗口求最值的现实应用)
- 动态规划中的优化问题(动态规划中对状态转移中的最值进行优化,提高算法效率,尤其是关于区间最值的动态规划问题,可以降低其时间复杂度)
- 其他应用于要维护单调性的问题中(例如求解数组中第K大元素等)
单调队列未必一成不变,单调队列只是一种思想,保证队列里单调递增或者递减即可;
前k个高频元素:优先级队列实现;
元素出现的频率要进行统计,并且元素的值和元素的频率要对应,所以要选择合适的数据结构记录元素的值及其频率,选择键值对,即使用map
;我们要对频率进行排序,但是对于元素的值我们不关心其顺序,所以使用key
无序的unordered_map
存储键值对;
数据结构选择了,其次我们要选择什么算法?直接使用优先队列,优先队列和stack
、queue
一样,都是容器适配器;在C++中,优先级队列基于堆(heap
)数据结构实现。优先级队列中的元素按照一定的优先级顺序排列,每次取出队首元素都是当前最高(或最低)优先级的元素。
#include <queue>
#include <functional> // std::greater
std::priority_queue<int> pq; // 默认是基于 std::vector 实现的最大堆
std::priority_queue<int, std::vector<int>, std::greater<int>> minPQ; // 自定义元素的优先级,指定std::vector<int>作为底层容器,用 std::greater 创建最小堆
// 也可以自己写一个greater函数:
bool operator()(int a, int b) const {
// 比较函数将返回 true 如果 a 比 b 大,即实现逆序
return a > b;
}
使用大顶堆还是小顶堆?大顶堆每次弹出最大元素,我们还要考虑保留的逻辑;小顶堆每次弹出最小元素,我们丢弃即可,保证队列中有k
个元素即可;所以使用小顶堆;
/*
题目的要求分析:
1. 统计元素出现的频率;
2. 对频率排序;
3. 输出频率排序前K高的元素;
优先级队列:从队头取元素,从队尾添加元素;内部元素自动按照权值排序;
map有key和value,key可以是值,value可以是出现的频率,根据value进行排序,然后输出前k个key值;
思路步骤:
1. 构建map,key为元素的值,value为频率;
2. 构建小顶堆(将value作为小顶堆的值),保证堆的大小为k;
3. 构建小顶堆的结果构建key的数组;
*/
class Solution {
public:
// 小顶堆
class mycomparison {
public:
bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
return lhs.second > rhs.second;
}
};
vector<int> topKFrequent(vector<int>& nums, int k) {
// 要统计元素出现频率
unordered_map<int, int> map; // map<nums[i],对应出现的次数>
for (int i = 0; i < nums.size(); i++) {
map[nums[i]]++;
}
// 对频率排序
// 定义一个小顶堆,大小为k
priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pri_que;
// 用固定大小为k的小顶堆,扫面所有频率的数值
for (unordered_map<int, int>::iterator it = map.begin(); it != map.end(); it++) {
pri_que.push(*it);
if (pri_que.size() > k) { // 如果堆的大小大于了K,则队列弹出,保证堆的大小一直为k
pri_que.pop();
}
}
// 找出前K个高频元素,因为小顶堆先弹出的是最小的,所以倒序来输出到数组
vector<int> result(k);
for (int i = k - 1; i >= 0; i--) {
result[i] = pri_que.top().first;
pri_que.pop();
}
return result;
}
};
总结
C++中栈stack
、队列queue
、优先级队列priority_queue
都是容器适配器而不是容器,其底层结构可以选择不同的容器;容器适配器不存储元素,但是容器适配器提供了受限操作元素的接口,容器适配器的底层结构容器才存储元素。正因为如此,栈、队列、优先级队列中的元素未必是连续的,要看其底层容器选择了什么,如果底层容器选择了deque
,则元素是不连续的;
栈和队列都是受限访问的,而这一受限访问的原因在于底层容器是private
的,而元素存储在底层容器中,只能通过栈和队列定义在public
中的特定的成员函数进行操作;
栈在现实中的应用很多,包括操作系统的文件管理、编译器的括号匹配、递归过程中的递归栈等;
两个栈可以模拟实现队列,同样两个队列也可以模拟实现栈;
单调队列可以解决滑动窗口、股票涨跌峰值、动态规划算法优化等问题;单调队列的构造方法要知道,单调队列中,只维护了部分元素,这些元素整体保持单调,并且元素的相对位置不发生改变;
优先级队列也是容器适配器,优先级队列的本质就是一个大顶堆/小顶堆,可以找到数据中的最大值/最小值;