尽管听起来很陌生,但是栈和队列在数据结构中也是十分常用且使用广泛的数据结构。
其实举两个例子,一个是栈:一摞盘子,每次只能从最上面拿,那么后放的必然先被拿走:这是一个先进后出的结构(First In Last Out);
另一个是队列:你在食堂排队拿饭,先排的先付完钱吃饭:这就是一个先进先出的结构(First In First Out)。
就是这么简单啦~ 你还想栈和队列有什么特别牛X的定义哦
在开头我们已经知道了,栈的主要规则就是先进后出。我们使用一个数组存储这个栈中的元素,并用指针指向栈顶(就是这堆盘子的顶上)。定义代码如下:
template<class DataType>
class Stack {
public:
DataType data[MAX_SIZE];
int top = -1;
};
栈有4个常用操作,在stl的栈容器中也是这样:压栈、出栈、取栈顶、判空
和顺序表中的添加元素非常像,入栈操作将top向后移动一位:
void push(DataType d) {
if(++top == MAX_SIZE) throw "stack full";
data[top] = d;
}
出栈就是将栈顶元素移除,为了和stl中的栈统一,这边的出栈将不会返回任何值:
void pop() {
if(top == -1) throw "stack empty";
data[top--] = NULL; //其实只要top--就行
}
取栈顶只要读出data中位于top位置的元素的值就行了:
DataType peek() {
if(top == -1) throw "stack empty";
return data[top];
}
看代码:
bool empty() {
return top == -1;
}
一般的栈的定义中,data区是大小不可变的,这也就导致了像先前顺序结构中的问题,我们使用链式存储能够很好地解决这一问题。
链式栈在入栈时将新的节点插在头节点后,出栈只要指向头节点所指的节点的后一个节点就可以了。
代码大同小异,这边不再给出,可以自己写写看。
这其实很简单,就是一个top1从头往后,一个top2从后往前,如果相遇或者越界就是满了,代码自己写写看吧~
可以用栈模拟一些现实中的情况。
前缀和后缀表达式比较好做,先讲后缀了:
从前向后扫描表达式,遇到数字则压入栈中,遇到运算符则从栈中同时弹出两个数字并用该运算符对其进行计算(不要忘记运算合法性判断),将计算结果重新压入栈中,最后栈顶的元素(运算完成后栈中只有一个元素了)就是表达式的结果。
首先讲一种奇淫巧计:从后往前扫描表达式,然后规则和计算后缀表达式相同,最后栈顶的元素就是表达式的结果啦~
正规做法:
从前往后扫描表达式,遇到符号压入符号栈,如果符号后同时连着两个数字,则弹出一个符号并用该符号去计算这个两个数字,结果保存在一个变量中,然后继续向后扫描直到结尾,此时栈应当为空否则表达式就有问题。
中缀表达式一般不会把它拿出来单独计算,一般是转换成前缀或者后缀表达式然后进行计算。
在转换过程中运算符就像正常的计算一样有优先级,*、/ > +、-
下面给出转化为后缀表达式的过程:
这边直接给出传送门和我的代码:
传送门:剑指 Offer 09. 用两个栈实现队列
代码:
class CQueue {
public:
stack<int> a, b;
CQueue() {}
void appendTail(int value) {
a.push(value);
}
int deleteHead() {
if(b.empty()) {
while(!a.empty()) {
b.push(a.top());
a.pop();
}
}
if(b.empty()) return -1;
else{
int top = b.top();
b.pop();
return top;
}
}
};
顾名思义,元素是单调上升或者下降的栈,这个应用非常广泛,题目难度跨度也非常大,在这边不进行举例;P
大家都知道大爆搜(dfs)的递归过程就是由系统的栈辅助执行的,但是这个递归栈非常容易溢出,然后就寄了。。。我们使用非递归方式并用栈来存储步骤,这样就可以便于程序的执行。
题目也有很多,最常见的就是走迷宫了。
队列和栈类似,只是将其变成先进先出结构就行了。
注意,由于是从前往后取元素,队列需要同时记录插入位置rear和队顶位置front:
template<class DataType>
class Queue {
public:
DataType data[MAX_SIZE];
int front = -1;
int rear = -1;
};
void push(DataType d) {
if(++rear == MAX_SIZE) throw "queue full";
data[rear] = d;
}
void pop() {
if(front == rear) throw "queue empty";
data[++front] = NULL; //直接++front,个人习惯清空数据;P
}
DataType front() {
if(front == rear) throw "queue empty";
return data[front];
}
bool empty() {
return front == rear;
}
从上面的代码不难看出,判空仅仅是比较front和rear,如果数据已经存到MAX_SIZE且全部出队了,这时候数组中没有任何数据,但是依然会抛出queue empty
的异常,这时就发生了“假溢出”。为了解决假溢出的问题,我们可以使用循环队列。
和普通队列的不同之处就在于,循环队列使用了%MAX_SIZE
来对新位置进行计算,如果已经存到MAX_SIZE - 1
了,那么下一个位置是MAX_SIZE % MAX_SIZE
=0,于是将从头开始存储,这样就不会导致空间浪费。
void push(DataType d) {
if((rear + 1) % MAX_SIZE == front) throw "queue full";
rear = (rear + 1) % MAX_SIZE;
data[rear] = d;
}
void pop() {
if(front == rear) throw "queue empty";
data[front] = NULL;
front = (front + 1) % MAX_SIZE;
}
PS:取值、判空还是一样的
相较于链式栈,链式队列存储了头指针的同时存储了尾指针rear负责向队尾添加元素,其入队、出队操作大同小异,可以试着自己写写。
特别地,还可以将链式队列的结尾指向头节点这样可以减少一个指针。
。。。
对于一些数据,可以使用队列先进先出的特性方便地实现滑动窗口而不是使用双指针。
队列此时就是那个滑动的窗口,从头出来,从尾进去。
和深度优先不同,广度优先枚举了下一步的每种可能并对这种可能再来一次枚举,这时我们就使用了队列来记录第n步的每一种可能,这样队列中的每个数据都是成组且步骤数是递增的,也就是相较于上一步不断向外扩展,然后这就是bfs了(???,但确实就是这样)
尽管栈和队列概念很简单,但是由其衍生出的题目和技巧很多。