【数据结构】栈

来源:互联网 发布:java保存用户密码加密 编辑:程序博客网 时间:2024/05/22 02:21

栈ADT

栈( stack )是一个带有限制的表,它的插入和删除都只能在一个位置上进行,也就是只能在表的末端进行。这个末端就叫做栈顶(top).

1.栈模型

对栈的基本操作就是 push( 进栈 )和 pop( 出栈 ),前者等价于插入,而后者则是删除最近插入的元素。最近插入的元素在执行 pop 之前可以通过使用 top 例程进行考察。在栈 ADT 中,对空栈执行 pop 或 top 一般都被认为是一个错误。另一方面,执行 push 时空间用尽是一个实现限制,但不是 ADT 错误。

栈有时又叫做 LIFO(后进先出)表(Last in, First out list).普通的清空栈的操作和判断时候空栈的测试都是栈操作指令系统的一部分,但是,我们对栈所能够做的基本上也就是 push 和 pop 操作。栈顶元素是唯一一个可见元素。

2.栈的实现

由于栈是一个表,因此任何实现表的方法都能实现栈。显然,list 和 vector 都支持栈操作,99% 的时间他们都是最合理的选择。偶尔设计一种特殊目的的实现可能会更快。因为栈操作是常数时间操作,所以,除非在非常独特的环境下,否则是不可能产生任何明显的改进的。

对于这些特殊的时机,一种实现使用链接结构,而另一种实现使用数组。二者都简化了 在vector 和 list 中的思路。

2.1 栈的链表实现

栈的第一种实现是使用单链表,我们通过在表的前端插入来实施 push 操作,通过删除表前端元素实施 pop 操作。top 操作知识考察表前端的元素并返回它的值。有时候也把 top 操作和 pop 操作合二为一。

2.2 栈的数组实现

栈的另一种实现避免了链而且可能是更流行的方案。它用到来自 vector 的 push_back 和 pop_back,因此实现起来很简单。与每个栈相关联的是 theArray 和 topOfStack,对于空栈它是 -1(这就是空栈的初始化做法)。为将某个元素 x 推入栈中,我们使 topOfStack 增1然后置 theArray[topOfStack]=x。为了弹出栈元素,我们置返回值为 theArray [topOfStack],然后使 topOfStack 减1。

需要注意的是,这些操作不仅以常数时间运行,而且是以非常快的常数时间运行。在某些机器上。若在带有自增和自减寻址功能的寄存器上操作,则(整数的)push 和 pop 都可以携程一条机器指令。最现代化的计算机将栈操作作为它的指令系统的一部分,这个事实强化了这样一种理念。也就是栈很可能是在计算机科学中在数组之后最基本的数据结构。

3.栈的应用

可以确定的是,如果我们把操作限制在对一个表进行,那么这些操作会执行得很快。然而,同样的,这些不多的操作确实非常强大和重要。下面给出三个例子,其中第三个例子很深刻的阐述程序是如何组织的。

3.1 平衡符号

编译器检查程序的语法错误,但是常常由于缺少一个符号(如遗漏一个花括号或是注释起始符而引起编译器列出上百行的错误),但是真正的错误却并没与找出来。

因此,在这种情况下一个有用的工具就是检查是否每件事情都能成对出现的程序。于是,每一个右花括号,右方括号,以及右圆括号必然对应其相应的左括号,序列 ‘[()]’ 是合法的,但是序列 ‘[(])’ 就是非法的。很显然,不值得为这种事情编写一个大型程序,而且事实上检验这些事情是很容易。为了简单记,只用圆括号,方括号和花括号进行检验并且忽略出现的任何其他字符。

这个简单的做法会用到一个栈,叙述如下:

做一个空栈,读入文字直到文件尾。如果字符是一个开放括号,则将其推入栈中。如果字符是一个封闭括号,则当栈空时报错。否则,将栈元素弹出。如果弹出的符号的不是对应的开放符号,则报错。在文件尾部,如果非空则报错。

很明显,上面这个算法可以正确运行。显然,它是线性的,事实上它只需要对输入进行一趟检查。因此,它是 联机(on-line)的。而且是相当快的。当报错时决定如何处理可能需要做一些附加的工作——例如判断可能的原因。

3.2 后缀表达式

假设有一个编写计算器并且想要计算一趟外出购物的花费。为此,我们将一列数据相加并将结果乘以1.06,它是所购物品的价格以及附加的地方销售税。如果购物各项花销为 4.99、5.99、和 6.99,那么输入这些数据的自然方式将是:

4.99+5.99+6.991.06

随着计算器的不同,这个结果可能是想要的答案 19.05,或者是科学答案 18.39。最简单的四功能计算器都会给出第一个答案,但是许多专业计算器是知道乘法的优先级高于加法的。

另一方面,有些东西是需要上税的,而另一些东西是不需要上税的,因此,如果只有第一项和最后一项是要上税的,那么计算顺序

4.991.06+5.99+6.991.06

将会在科学计算器上给出正确答案(18.69)而在简单计算器上给出错误的答案(19.37)。科学计算器一般包含括号,因此我们总可以通过加括号的方法得到正确的答案,但是使用简单计算器时需要记住一些中间结果。

上面的例子计算顺序可以如下

将 4.99 和 1.06 相乘并存为A~1~;然后再将 5.99 和 A~1~相加,再将结果存入A~1~;我们再将 6.99 和 1.06 相乘的答案存为 A~2~,最后将 A~1~ 和 A~2~ 相加并将最后结果放入A~1~。可以将这种操作顺序书写如下所示

4.99 1.065.99+6.99 1.06+

上面这种记法叫做 后缀记法(postfix notation)或是 逆波兰记法(reverse Polish notation),其中求值的过程恰好是上面所描述的过程。计算这个问题最容易的方法是使用一个栈:

当遇到一个数字时,就将其入栈,在遇到一个运算符时该运算符就作用域从该栈弹出的两个数(符号上),再将所得的结果推入栈中。

计算一个狗追表达式花费的时间是 O(N),因为对输入中的每个元素的处理都是由一些栈操作组成从而花费常数的时间。这种算法的计算非常简单。注意,当一个表达式以后缀记号给出时,没有必要知道任何优先的规则。而这也正是逆波兰表达式的一个明显优点。

3.3 中缀到后缀的转换

栈不仅仅可以用来计算后缀表达式(postfix expression) 的值,而且还可以用栈将一个标准形式的表达式(也叫做中缀式*(infix)* )转换为后缀式(postfix)。我们通过只允许操作 +、*、(、),并坚持普通的优先级法则而将一般的问题浓缩成为小规模的问题。同事还有进一步假设表达式是合法的。

假设要将中缀表达式:

a + b  c + ( d  e + f )  g

转换成后缀表达式。正确的答案是:

a b c +d e  f+g +

当读到一个操作数的时候,立即把它放到输出中。但是运算符并不立即输出,而是必须先存在某个地方。正确的做法是将已经见到的运算符放进栈中而不是放到输出中。当遇到左圆括号时也要将其推入栈中。计算从一个空栈开始。

如果遇到一个右括号,那么就将栈元素弹出,将弹出的符号写出直至遇到一个(对应的)左括号为止,但是这个左括号只被弹出而并不输入。

如果见到其他任何的符号,[如+、*、(],那么我们从栈中弹出栈元素直到发现优先级更低的元素为止。有一个例外:除非是在处理一个)的时候,否则我们绝补从栈中移走(。对于这种操作,+的优先级最低,而(的优先级最高。当从栈弹出元素的工作完成后,我们再将运算符压入栈中。

最后,如果读到输入的末尾,则将栈元素弹出直到这个栈变成空栈,再将这些符号写到输出中。

这个算法的思路是:当看到一个运算符的时候,先把它放入栈中,栈代表挂起的运算符。然而,栈中有些具有高优先级的运算符现在知道需要完成使用,应该被弹出,他们将不再处于挂起的状态。这样,在把当前运算符放入栈中之前,那些在栈中并在当前操作符之前要完成使用的运算符要被弹出。具体解释如下表:

表达式 在处理第3个运算符时的栈状态 动作 a*b-c+d -
    完成,+进栈
a/b+c*d + 没有用算符完成操作,*进栈 a-b*c/d -* *完成,/进栈 a-b*c+d -* *和 - 完成,+进栈

圆括号增加了额外的复杂性。当左括号是一个输入符号时可以把它看成是一个高优先级的运算符(这样,挂起的那些操作符仍然是挂起的),而当它在栈中时把它看成是低优先级的运算符,从而不会被一个操作符意外的删除,右括号被处理成为特殊的情况。

与前面相同,这种转换工作也只需要 O(N) 的时间并经过一趟输入后完成。可以通过指定减法和加法有相同的优先级以及乘法和除法有相同的优先级而将减法和除法添加到指令集中去。需要注意的是,表达式 abc 应该转换成为 abc 而不是转换成为 abc。我们的算法可以得到正确的结果,因为这些操作符是从左到右结合的。一般情况未必如此,比如下面的表达式就是从右向左结合的: 223=28=256 而不是 43=64

3.4 函数调用

检测平衡符号的算法提出一种在编译的过程语言和面向对象的语言中实现函数调用的方式。这里的问题是,当调用一个新函数 时,主调例程的所有局部变量需要由系统存储起来,因为否则被调用的新函数将会重写由主调例程所使用的内存。不仅如此,主调例程的当前位置必须存储起来,以便在新函数运行完成之后知道要向哪里转移。这些变量一般由编译器指派给机器的一些寄存器,但是在某些冲突(通常所有的函数都是获取指定给1号寄存器的某些变量),特别是涉及到递归的时候。该问题类似于平衡符号,原因是函数调用和函数返回基本上类似于开括号和闭括号。

当存在函数调用的时候,需要存储的所有重要信息,诸如寄存器的值(对应一些变量的名字)和返回地址(它可以从程序计数器得到,一般情况在一个寄存器中)等,都要以抽象的方式存在“一张纸上”并置于堆(pile) 的顶部。然后控制转移到新函数,这样就可以自由地用它的一些值替换这些寄存器。如果它有进行其他的函数调用,那么也遵循相同的过程。当函数要返回时,查看堆(pile) 顶部的那张“纸”,并复原所有的寄存器,然后进行返回转移。

显然,全部工作都可以由一个栈来完成,而这也正是在实现递归的每一种程序设计语言中实际发生的事实。所存储的信息可以称为活动记录( activity record),或是叫做栈帧( stack frame)。在典型情况下,需要做轻微的调整:

当前环境由栈顶描述。因此,一条返回语句就可以给出前面的环境( 不用复制 )。在实际计算机中的栈常常是从内存分区的高端向下增长,而在许多的系统中是不检测溢出的。由于有太多的同事在运行着的函数,因此用尽栈空间的情况总是可能发生的。显而易见,用尽栈空间常是致命的错误。

在不进行栈溢出检测的语言和系统中,程序将会崩溃而没有明显的说明。在正常情况下我们不应该越出栈空间,发生这种情况通常是应为失控递归 导致的。另一方法,在某些表面上无害的程序也可以越出栈空间。

就像下面这个程序,功能是打印一个容器中的元素,这个例程完全合法,实际上也是正确的。它正常的处理空容器的基准情形,并且递归也没有问题。可以证明这个程序是正确的。

然而遗憾的是,如果这个容器有200 000 个元素要打印,那么就要有表示第11 行上嵌套调用的 200 000 个活动记录的一个栈。一般这些活动记录由于它们包含的信息而特别庞大,因此这个程序可能会越出栈空间。

/**  * 从起点开始答应容器直到尾端,但是不包括尾端  */template <typename Iterator>void print( Iterator start, Iterator end, ostream & out = cout){    if( start == end )      return ;    out << *start++ << endl;        //打印并推进起点    print(start ,end, out );}

上面这个程序是成为 尾递归(tail recursion) 的使用极为不当的例子。尾递归指的是在最后一行进行递归的调用。尾递归可以手工消除,做法通常是 将代码放到一个while 循环体中并借助每次对象参数的以此赋值代替递归调用。

这种方法它模拟了递归调用,因为什么也不需要存储。在每次递归调用阶数之后,实际上没有必要直到那些存储的值。因此,我们就可以带着在以此递归调用中本应用过的那些值转移到函数的顶部。

下面这段程序就是改进后的这段程序。

需要说明的是,q去除尾递归是如此的简单,以至于某些编译器能够自动的完成。但是即使如此,最好还是不要让自己的程序中存在尾递归。

/**  * 从起点开始答应容器直到尾端,但是不包括尾端  */template <typename Iterator>void print( Iterztor start, Iterator end, ostream & out = cout){    while( true )    {        if( start == end)          return;        out << *start++ << endl;    }}

递归总是能够被彻底的去除(编译器是在转变成汇编语言时完成递归去除的),但是这么做是相当冗长且乏味的。一般的做法是要求使用一个栈,而且仅当能够把最低限度的最小值放到栈上时这个方法才值得一用。

最后放上栈的两种实现。

4. 栈的实现

4.1 栈的向量实现

#ifndef STACK#define STACK#include <iostream>template<class T, int capacity = 30>class Stack{public:    Stack() {      pool.reverse(capacity);    }    void clear()    {      pool.clear();    }    bool isEmpty() const{      return pool.empty();    }    T& topEL() {      return pool.back();    }    T pop() {      T e1 = pool.back();      pool.pop_back();      return e1;    }    void push( const T &e1 )    {      pool.push_back( e1 );    }private:    vector<T> pool;}

4.2 栈的链表实现

#ifndef LL_STACK#define LL_STACK#include <list>template<class T>class LLStack{    LLstack()   {    }    void clear(){        lst.clear();    }    bool isEmpy() const {        return lst.empty();    }    T& topE1()    {        return lst.back();    }    T pop()    {        T e1 = lst.back();        lst.pop_back();        return e1;    }    void push(const T& e1)    {        lst.push_back(e1);    }  private:    list<T> lst;}
原创粉丝点击