数据结构 栈与队列
栈与队列(优先队列、双端队列)是一类特殊的线性表。
顺序栈和链式栈
栈的存储方式有两种,基于数组和基于链表,分别被称为顺序栈和链式栈。
栈与括号匹配
括号匹配问题,某个字符串中的三类括号能否匹配,只需将左括号入栈,每次比较当前字符和栈顶字符是否匹配即可。
#include <iostream>
#include <stack>
using namespace std;
int main(){
string in;
cin>>in;
stack<char> s;
for(char c : in){
// 假如是左括号,那么入栈
if(c=='(' || c=='[' || c=='{'){
s.push(c);
}// 假如是右括号,并且栈空
else if(s.empty() && (c==')' || c==']' || c=='}')){
cout<<"No"<<endl;
return 0;
}// 假如是右括号,并且匹配
else if(c==')' && s.top()=='(' || c==']' && s.top()=='[' || c=='}' && s.top()=='{'){
s.pop();
}else{
cout<<"No"<<endl;
return 0;
}
}
if(s.empty()) cout<<"Yes"<<endl;
else cout<<"No"<<endl;
}
表达式计算
计算机中的表达式有操作数、操作符和分界符组成,通常,算数表达式有三种表示:
中缀表示:<操作数><操作符><操作数>
前缀表示:<操作符><操作数><操作数>
后缀表示:<操作数><操作数><操作符>
类似树的前中后序遍历(表达式本身也是一棵树,树的层级刚好就是表达式的优先级)
中缀表示中,需要根据每个表达式的优先级来确定计算顺序,还有括号运算符,因此和前缀一样,都需要两个栈来实现;后缀则只需要一个。
计算后缀表达式的值
后缀表达式,又称为逆波兰(RPN)表达式,算法如下:
- 自左至右顺序扫描表达式的每一项,根据其类型做如下操作:
- 如果是该项是操作数,那么直接入栈
- 如果该项是操作符,那么栈顶的两个元素出栈,与操作符进行计算,结果入栈
- 最终扫描完成后,栈顶的元素就是表达式的值
int evalRPN(vector<string>& tokens) {
stack<string> s;
// 自左向右读取
for(string str : tokens){
// 操作符
if(str.size() == 1 && (str[0] == '+'||str[0] == '-'||str[0] == '*'||str[0] == '/')){
int a = stoi(s.top());
s.pop();
int b = stoi(s.top());
s.pop();
if(str[0] == '+'){
s.push(to_string(a+b));
}
if(str[0] == '-'){
s.push(to_string(b-a));
}
if(str[0] == '*'){
s.push(to_string(a*b));
}
if(str[0] == '/'){
s.push(to_string(b/a));
}
// 操作数
}else{
s.push(str);
}
}
return stoi(s.top());
}
将中缀表达式转化为后缀表达式
在中缀表达式中,操作符的优先级和括号使得求解的过程被复杂化,把它转化为后缀表达式后,求解变得简单。为了实现中缀向猴嘴的转化,需要考虑各个操作符的优先级。
在本算法中,有两种优先级,栈内优先级isp和栈外优先级icp,其值如下:
操作符ch | # | ( | *,/,% | +,- | ) |
---|---|---|---|---|---|
Isp | 0 | 6 | 5 | 3 | 1 |
Icp | 0 | 1 | 4 | 2 | 6 |
可以看到,一般操作符的栈内优先级比栈外优先级高1,这说明同样优先级的操作符满足左结合律。括号的优先级在内外分别是最低和最高,说明其不重要,为分割符,主角是操作符与操作数。内外优先级相等的情况仅发生在括号匹配或者结束符#处,前者将使得字符继续向下读入,后者将结束本次转化。
算法如下:
-
初始化。栈内为开始结束符#,当前读入的字符ch为中缀的第一个字符
-
自左至右读取中缀表达式ch
-
若为操作数,直接输出
-
若为操作符,比较当前读取的中缀字符ch的栈外优先级icp(ch)和栈顶操作符op的栈内优先级isp(op)
-
若icp(ch) > isp(op) ,ch入栈
-
若icp(ch) < isp(op) ,依次输出所有isp大于icp(ch)的栈顶元素,然后判断栈顶元素和icp(ch)关系
-
若icp(ch) > isp(栈顶元素),说明ch为正常运算符,入栈
-
若icp(ch) == isp(栈顶元素),说明ch为括号或者开始结束符,栈顶元素出栈
-
-
若icp(ch) == isp(op) ,op出栈但不输出(同理这时有两种,括号和结束符,都不需要输出)
-
-
最终输出的序列就是后缀表达式。
也可以按照当前中缀序列元素的类型来进行操作:
- 若为操作数,直接输出
- 若为操作符,比较与栈顶元素的优先级,大于则入栈,小于则出栈并输出直到大于或左括号或结束符,然后入栈
- 若为左括号,入栈
- 若为右括号,一直出栈并输出直到左括号,然后左括号出栈但不输出
- 若为结束符,一直输出到栈顶为结束符,然后结束
#include <iostream>
#include <string>
#include <map>
#include <stack>
using namespace std;
int main(){
// 这是输入的中缀表达式序列,注意前面没有开始结束符"#",否则第一步就挂了
string in[] = {"A","+","B","*","(","C","-","D",")","-","E","/","F","#"};
// 事先规定的各个符号的优先级
map<char,int> isp,icp;
isp['#'] = 0;isp['('] = 1;isp['*'] = isp['/'] = isp['*'] = 5;isp['+'] = isp['-'] = 3;isp[')'] = 6;
icp['#'] = 0;icp['('] = 6;icp['*'] = icp['/'] = icp['*'] = 4;icp['+'] = icp['-'] = 2;icp[')'] = 1;
// 用一个栈来完成转换,栈内初始化为1个开始结束符"#"
stack<string> s;
s.push("#");
// 读取中缀表达式序列,转化为后缀表达式并输出
for(string ch : in){
// 假如是操作符,这里判断可用map的find函数,该函数返回迭代器
if(ch.size() == 1 && isp.find(ch[0])!=isp.end()){
// 栈顶操作符op
string op = s.top();
// 假如外优先级大于内,那么外来的新操作符入栈
if(icp[ch[0]] > isp[op[0]]){
s.push(ch);
}
// 假如外优先级小于内,那么栈顶操作符输出并出栈,直到外优先级d不小于内
else if(icp[ch[0]] < isp[op[0]]){
while(isp[s.top()[0]] > icp[ch[0]]){
cout<<s.top();
s.pop();
}
// 此时若外优先级大于内,说明外来的新操作符非括号与结束符,那么入栈
if(icp[ch[0]] > isp[s.top()[0]]){
s.push(ch);
}
// 否则,为括号或者结束符,依据匹配的原则,还要将栈顶元素出栈
else{
s.pop();
}
}
// 假如内外优先级相等,说明二者是互相匹配的括号或者开始结束符,出栈即可
else{
s.pop();
}
}
// 假如是操作数
else{
cout<<ch;
}
}
}
栈与递归
栈和递归密切相关,递归函数在调用时有外部调用和内部调用之分。外部调用结束后,返回调用该过程的主程序;内部调用结束后,将返回到递归过程中本次内部调用的下一个语句处。因此在每一次内部调用中,都要为函数的形参传递、函数返回位置的记录等预留空间,,这些内容在每次递归函数调用时被打包加在某个栈的栈顶,称为递归工作栈。
利用栈将递归改为非递归
为了减少函数调用开销,可以用一个栈来记录递归的过程,将递归转化为非递归,例如树的前中后序遍历,都可以用栈来代替递归。
利用迭代求解递归
一些递归问题常常可以用迭代的方式解决,例如递归语句发生在函数最后一行的尾递归,或者是从大到小单调递归的单向递归。
递归法求解汉诺塔问题
汉诺塔是递归的经典问题。A,B,C三个柱子,起初A柱子上有n个上小下大排列的盘子,问全部移到C柱子上需要移动多少次,其中所有盘子只能小的在大的上面。
#include <iostream>
#include <string>
using namespace std;
// 每个问题我们都认为是把A柱子上的n个盘子借助B柱子移动到C柱子,第一个参数是起始柱,第二个是中转柱,第三个是目标柱
void hanoi(int n, string A, string B, string C){
if(n == 1){
cout<<A<<"->"<<C<<endl;
}else{
hanoi(n-1, A, C, B);// 前n-1个先借助C柱转移到B柱
cout<<A<<"->"<<C<<endl;// 然后将最下面的一个转移到C柱
hanoi(n-1, B, A, C);// 然后将那n-1个转移到C柱
}
}
int main(){
hanoi(3, "A", "B", "C");
}
队列、循环队列与链式队列
队列利用数组实现最基本的顺序队列,但是这可能有一些溢出和空间利用的不充分;利用模运算可以将数组的空间充分利用,也就是实现上的循环队列;利用链表实现的队列就是链式队列了。
打印杨辉三角
利用队列可以打印杨辉三角的前n行,其中杨辉三角的第i行就是多项式(a+b)i的系数
#include <iostream>
#include <queue>
using namespace std;
void YANGVI(int n){
// 队列初始化为第一行
queue<int> q;
q.push(1);q.push(1);
// 逐行处理
for(int i = 1;i <= n;i++){
cout<<endl;
// 读到第一个1,说明上一行已经被读完了,本行已经全部入队,加一个本行结束的1
if(q.front() == 1){
cout<<1<<" ";
q.push(1);
q.pop();
q.push(1+q.front());
}
while(q.front() != 1){
cout<<q.front()<<" ";
int tmp = q.front();
q.pop();
q.push(tmp+q.front());
}
// 读到第二个1,说明这一行已经被读完了,下一行已经全部入队,加一个下一行开始的1
cout<<q.front()<<" ";
q.push(1);
q.pop();
}
}
int main(){
YANGVI(5);
}
一般逐行处理的情况都少不了用队列作为其辅助工具。
优先级队列
优先级队列每次从队列中取出的应该是具有最高优先权的元素。
#include <iostream>
#include <queue>
using namespace std;
// 基本类型的堆
priority_queue<int> pq1; // 默认为大顶堆
priority_queue<int,vector<int>,greater<int>> pq2;// 这样写就是小顶堆
// 对象的堆
// 第一种方式,重载<运算符
struct node1{
int n;
bool operator < (const node1 &a) const {
return this->n > a.n;// 这样写就是小顶堆,和前面的greater一样,这是一个思维的反常点
}
};
priority_queue<node1> pq3;
// 第二种方式,重写仿函数,即重载运算符()
struct node2{
int n;
bool operator () (node2 &a,node2 &b) const {
return a.n > b.n;// 这样写就是小顶堆,和前面的greater一样,这是一个思维的反常点
}
};
priority_queue<node2> pq4;
int main(){
// 获取堆顶元素
pq1.top();
// 插入元素
pq1.push(1);
// 删除元素
pq1.pop();
}
双端队列
双端队列可以在队列的两端进行插入和删除。