基本概念
栈的定义及特点
- 栈(stack)是一种只能在一端插入和删除的线性表。允许插入删除的一端称为栈顶(top),反之的一端称为栈底(bottom)。
- 栈满足特性:后进先出(Last-in First-Out, LIFO)。
栈的基本操作
名称 | 作用 |
---|---|
create() | 创建一个长度为定值的新栈 |
destory(s) | 销毁栈s ,释放内存空间 |
empty(s) | 返回栈s 是否为空 |
push(s, v) | 将元素v 插入栈s |
pop(s) | 将栈s 弹出一个元素 |
top(s) | 返回栈s 栈顶元素 |
- 所用的教材,和《算法》直接在出栈的同时返回栈顶的值。而《数据结构与算法分析C语言描述》书中的
stack
和C++的STL的stack
的实现中,pop()
操作并不返回任何值,而依靠独立操作top()
返回栈顶元素。3.2节的队列,《算法》的描述,也有一些区别。- x86指令集也具有
PUSH
和POP
指令,其中,PUSH
指令将寄存器的值压栈,而POP
指令将栈顶元素放入寄存器,并出栈。在IA32架构体系中,栈帧结构是实现函数调用的基本模型(可以参考《深入理解计算机系统》)。因此对于递归程序,栈这一数据结构非常重要。利用栈可以将递归程序非递归化处理。
栈的实现
栈与线性表
因为栈本质上是一种特殊的,操作受限的线性表,因此可以直接在线性表的基础上加以扩充,就得到栈这一数据结构了。在STL中,栈、队列并不是容器,而是容器配接器(container adapter)。
在STL的stack的类定义如下:
template<class T, class Sequence = deque<T> >
class stack{
protected:
Sequence c; //底层容器
public:
//其他内容从略
}
因此,在实现stack
的数据结构,我们可以直接利用线性表的相关数据结构进行操作,比如顺序表和链表。STL的Sequence
使用的是deque
容器实现,deque
实际上也可以用双向循环链表简单地实现(当然STL为了使deque
兼有vector
的随机迭代器(random access iterator),实现起来复杂得多)。
具体的实现层面,实现一个栈,只需要原始容器具备在一端插入和删除的操作就可以了,更进一步,他们的操作时间都必须是 O(1) 。
因此用顺序表实现栈的过程中,只需要一个栈顶指针,指向顺序表的最后一个元素;用链表实现时,因为在头结点之后无论是插入和删除都是常数时间,因此均可以用在头部插入和删除完成操作。
顺序栈
首先利用2.1节的顺序表SeqList<_Ty>
实现一个有容量限制的顺序栈。因为顺序表的栈只有在末尾插入的时间是
O(1)
,因此入栈、出栈都需要利用_list.length()
获取其最后一个元素的逻辑序号。
/**
* 顺序栈类
*/
template<typename _Ty, int capacity = 64>
class SeqStack{
private:
SeqList<_Ty, capacity> _list;
public:
/**
* 构造函数,创建一个空栈
*/
SeqStack(){}
/**
* 析构函数
*/
~SeqStack(){}
/**
* 判断栈是否为空
* @return bool 空栈返回true,否则false
*/
bool empty(){
return _list.empty();
}
/**
* 返回栈顶元素
* @return _Ty & 栈顶元素
*/
_Ty & top(){
assert(!_list.empty());
return _list[_list.length()];
}
/**
* 将元素入栈
* @param _Ty & value 需要入栈的元素
*/
void push(_Ty value){
_list.insert(_list.length() + 1, value);
}
/**
* 将栈顶元素出栈
*/
void pop(){
assert(!_list.empty());
_list.remove(_list.length());
}
/**
* 返回栈的实际长度
* @return int 栈的实际长度
*/
int length(){
return _list.length();
}
};
链栈
下面是利用2.3节的单链表类SinglyLList<_Ty>
实现的一个栈。使用类的内联函数写法,因为操作比较简单。链式栈的优点是不存在栈的容量上限。其实如果用vector
向量容器而不是定长顺序表array
容器或者直接C风格数组
作为顺序存储结构的底层容器,vector
支持自动扩容,且摊还时间为
O(1)
,也是可以实现一个高效的栈。
/**
* 链式栈类
*/
template<typename _Ty>
class lstack{
private:
SinglyLList<_Ty> _list;
public:
/**
* 构造函数,创建一个空栈
*/
lstack(){}
/**
* 析构函数
*/
~lstack(){}
/**
* 判断栈是否为空
* @return bool 空栈返回true,否则false
*/
bool empty(){
return _list.empty();
}
/**
* 返回栈顶元素
* @return _Ty & 栈顶元素
*/
_Ty & top(){
assert(!_list.empty());
return _list[1]; //首元素的逻辑序为1
}
/**
* 将元素入栈
* @param _Ty & value 需要入栈的元素
*/
void push(_Ty value){
_list.insert(1, value);
}
/**
* 将栈顶元素出栈
*/
void pop(){
assert(!_list.empty());
_list.remove(1);
}
/**
* 返回栈的实际长度
* @return int 栈的实际长度
*/
int length(){
return _list.length();
}
};
实际上,直接手写一个新的链表,或者直接用数组表示一个栈也是很容易的,这里只是为了演示栈是一种特殊的线性表,可以利用线性表的操作来完成而已。
共享栈
共享栈的实现实际上并不是那么重要,在《数据结构与算法分析C语言描述》只是作为一个练习题呈现。在一个数组中实现两个栈,只需要维护两个栈顶指针就可以了。判断这两个栈是否相等,实际是就是判断这两个指针相等。这种做法几乎没有什么实际用途,因此具体实现就不写了。
栈的应用
标记配对问题
对于标记配对问题,用栈可以很简单的实现。从文件开头读取到文件末尾,当扫描到开始标记时,将其入栈;扫描到结束标记,需要判断其是否与栈顶的开始标记匹配。当扫描到末尾栈非空,或者扫描到结束标记时栈空,则标记不配对。只有当扫描到文件结束,且栈空,标记才能算是完全匹配。
下面是一个简单的实现,只检查包括(),[],{}
三种标记的配对情况,很容易扩展到更多标记的情况。至于需要实现具体语言中的标记配对,在编译原理中有着更好的解决办法。
/**
* 检查一个字符串(只包含圆括号,中括号,大括号)是否配对
* @char * str 需要检查的 C-Style 字符串
* @return bool 返回标记是否匹配,匹配则true,反之false
*/
bool check(char * str){
std::stack<char> s;
//使用stl的stack,并没有使用自制的栈
for (char * p = str; *p != 0; ++p){
if (*p == '[' || *p == '(' || *p == '{'){
s.push(*p);
}else if(* p == ']' || *p == ')' || *p == '}'){
if (s.empty()){
return false;
} else {
if (*p == ']'){
if (s.top() != '[')
return false;
} else if (*p == '}'){
if (s.top() != '{')
return false;
} else if (*p == ')'){
if (s.top() != '(')
return false;
}
s.pop();
}
}
}
return s.empty();
}
表达式转换与求值
给定一个只包含数字、四则运算符、圆括号的简单表达式,对其进行求值。
对于日常中的中缀表达式(infix expression),要先将其转化为后缀表达式(postfix expression,又叫逆波兰表达式),然后对其进行求值。
将中缀表达式转化为后缀表达式
(1)扫描到优先级低于栈顶元素的符号,需要将栈清空或者栈顶元素的优先级低于当前符号,并将当前符号入栈;
(2)扫描到优先级高于栈顶元素的符号,直接将运算符入栈;
(3)扫描到优先级相同的符号,将栈顶元素出栈,然后将该元素入栈。
(4)扫描到左圆括号,将左圆括号入栈。
(5)扫描到右圆括号,将栈进行弹出,弹出到左圆括号被弹出为止。(6)如果序列扫描结束,应该将栈中的所有元素进行弹出。
/**
* 将中缀表达式转换为后缀表达式
* @param const string & infix_exp 需要转换的中缀表达式
* @return string 转换后的后缀表达式
*/
std::string infix_to_postfix(const std::string & infix_exp){
using std::string;
using std::stack;
stack<char> s;
string result;
for (int i = 0; i < infix_exp.length(); ++i){
if (infix_exp[i] == '+' || infix_exp[i] == '-'){
while (!s.empty()){
if (s.top() == '(')
break;
result.push_back(s.top());
s.pop();
}
s.push(infix_exp[i]);
} else if (infix_exp[i] == '*' || infix_exp[i] == '/'){
while (!s.empty()){
if (s.top() != '*' && s.top() != '/')
break;
result.push_back(s.top());
s.pop();
}
s.push(infix_exp[i]);
} else if (infix_exp[i] == '('){
s.push('(');
} else if (infix_exp[i] == ')'){
while (!s.empty() && s.top() != '('){
result.push_back(s.top());
s.pop();
}
s.pop(); //弹出多余的'('
} else{
while (infix_exp[i] >= '0' && infix_exp[i] <= '9'){
result.push_back(infix_exp[i++]);
}
i--; //修正多加了一次i
result.push_back('#'); //约定以'#'作为数字结束符
}
}
while (!s.empty()){
result.push_back(s.top());
s.pop();
}
return result;
}
对后缀表达式求值
对后缀表达式求值十分容易,只需要一个操作数栈,每扫描到一个操作符,就从操作数栈上依次取两个数,运算完成后将结果放在操作数栈上就可以了。最终,栈顶元素(实际上也只有1个元素)就是表达式的值。
/**
* 计算后缀表达式的值
* @param string & postfix_exp 后缀表达式
* @return double 返回表达式的值
*/
double eval_postfix(const std::string & postfix_exp){
std::stack<double> s;
double operand1, operand2;
for (int i = 0; i < postfix_exp.length(); i++){
if (postfix_exp[i] >= '0' && postfix_exp[i] <= '9'){
int val = 0;
while (postfix_exp[i] != '#'){ //前面定义的结束符
val = val * 10 + postfix_exp[i++] - '0';
}
s.push((double)val);
} else if (postfix_exp[i] == '+' || postfix_exp[i] == '-' ||
postfix_exp[i] == '*' || postfix_exp[i] == '/'){
//注意减法、除法不满足交换律
//需要注意操作数顺序(因为栈是LIFO的)
operand1 = s.top();
s.pop();
operand2 = s.top();
s.pop();
if (postfix_exp[i] == '+'){
s.push(operand2 + operand1);
} else if (postfix_exp[i] == '-'){
s.push(operand2 - operand1);
} else if (postfix_exp[i] == '*'){
s.push(operand2 * operand1);
} else if (postfix_exp[i] == '/'){
s.push(operand2 / operand1);
}
}
}
return s.top();
}
使用两个栈直接求值(Dijkstra 2-Stack Algorithm)
迷宫问题
迷宫问题是指,把迷宫抽象成一个二维数组,用0
表示通路,1
表示墙壁,求从某一点到另一点的路径。
迷宫问题可以用栈(DFS)解决,也可以用后面的队列(BFS)解决。
用栈解决迷宫问题属于一种回溯法的思想,即一步步深入问题,如果遇到不可能的情况就立刻返回上一种情况,在此基础上继续寻找可能的情况。栈这一数据结构LIFO的特性恰好是满足要求的。
下面简要给出迷宫问题算法的循环不变式:
【循环起始条件】
将起点进栈,设置起点已经走过。
【用栈解决维持的循环不变式】
维持栈不空的一个状态,取栈顶元素表示的格子,向四周探查可以继续行走的格子。如果找到了可以行走的格子,需要将这个格子设置为已经走过,并将其入栈。如果没有可以行走的格子,则将当前元素的格子出栈,并清除走过的标记。
【循环终止】
栈空,或者下一个元素就是终点。
Summary
栈的基本特性和操作
栈的本质是一个操作受限制的线性表,只能在一头进行插入和删除操作,分别称为
push
和pop
。因此在使用栈进行数据操作时,往往会导致逆序的情况出现。
栈的基本实现
栈可以用数组或者链表进行实现,实现的过程中应该保证其最坏时间都是 O(1) ,也就是说,要充分利用底层容器(存储结构)的特点。对于数组实现,应该将其放在尾部进行操作;对于单链表,应该放在头部进行操作。
- 栈的应用
这里列举了一些栈的应用:标记对配对、表达式转换与求值、求解迷宫问题。这些问题都利用了栈的LIFO特性,即后面要处理的内容,跟最后处理的元素有关联。
栈也是实现递归程序非递归化的一个重要手段。