『算法』栈的奇妙冒险

阅读本文需要 6 分钟

接着算法与数据结构专题,建议提前阅读上一篇 算法 | 如何从 0 开始用链表实现一个 LRU 缓存

今天内容是栈,经过前面的对算法,数据结构等内容的介绍,对这个专题的内容大概有了一个基本的认识了,所以,接下来,我也就直接一点了。

栈是一种操作受限的线性表,它只能在一端进行操作。举个例子:我们吃饭的盘子,需要叠好放进碗柜里,取盘子只能从最上面取,放盘子也只能往最上面放,就会有最先放上去的盘子,最后才能取出来这样的特性。即先进后出或者后进先出,这就是栈的特性。

当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出,先进后出的特性就可以完美地使用栈来实现。

如何实现一个栈
从开头的描述可以看出,栈主要有两个操作,入栈和出栈,即在栈顶插入或者删除一个数据。

栈可以用数组实现叫做顺序栈,也可以用链表实现叫做链栈。

特别地,对于基于数组的栈,有一个缺点就是,一般栈的大小都是固定的。实际情况下,我们难以确定我们所需的空间,会造成空间的浪费或者空间不够这样的极端情况。所以,我们在使用的时候,采用动态扩容的方式来实现。

实现一个动态扩容的栈思路很简单,无非就是在入栈的时候检测一下栈是否已满,如果满了,就开辟一块更大的空间,并将此数组全部复制到另外一个更发的数组中去,同时释放该数组空间。

这里基于一个数组实现一个可以自动扩容的顺序栈:

/** 构造函数,创建栈
    *类模板成员函数的写法  template<class T> 返回值类型 类名<T>::成员函数名(参数列表){}
    */
template<class T> MyStack<T>::MyStack()
{
    this -> count = 10;  //初始化栈的大小为 10
    this -> flag = 0;   // 栈顶元素索引
    this -> array = new T[this -> count];
    if (! this -> array){
        cout << "stack malloc memory failed! " << endl;
    }
}


/** 有参构造函数,创建栈  **/
template<class T> ArrayStack<T>::ArrayStack(int count)
{
    this -> count = count;
    this -> flag = 0;
    this -> array = new T[this -> count];
    if (! this -> array){
        cout << "stack malloc memory failed!" << endl;
    }
}

/** 析构函数,销毁栈 **/
template <class T> ArrayStack<T>::~ArrayStack(){
    this -> count = 0;
    this -> flag = 0;
    if(this -> array){
        delete [] this -> array;
        this -> array = NULL;
    }

}
/** 入栈 **/
template<class T> void ArrayStack<T>::push(T data){
    if(this -> flag == this -> count){  // 栈满了,自动给容1.5倍
        cout << "The stack is full , so need to enlarge 1.5x! "<< endl;
        this -> count = int (1.5 * this -> count);
        T * temp = new T [this -> count];
        for(int i = 0; i < this -> flag ; i++){
            temp[i] = this -> array[i];
            //cout << temp[i] <<endl;
        }
        delete [] this -> array;       //释放原来的空间
        temp[this -> flag] = data;
        this -> flag ++;
        this -> array = temp;
    }
    else{
        this -> array [this -> flag] = data;
        this -> flag ++ ;
    }
}

/** 出栈,并删除栈顶元素 **/
template<class T> T ArrayStack<T>::pop(){
    this -> flag --;
    T temp = this -> array[this -> flag];
    return temp;
}

/** 出栈,不删除栈顶元素  **/
template<class T> T ArrayStack<T>::peek(){
    T temp = this -> array[this -> flag - 1];
    return temp;
}
/**返回当前栈的元素个数  **/
template<class T> int ArrayStack<T>::stackSize(){
    return this -> flag;
}
/**返回当前栈的容量  **/
template<class T> int ArrayStack<T>::stackMaxSize(){
    return this -> count;
}

上面基于数组实现的顺序栈,加深了对栈的理解,问题来了,它的操作时间和空间复杂度为多少呢?

不管是顺序栈还是链栈,我们存储数据只需要一个大小为n的数组。在入栈和出栈过程中,只需要一两个临时变量存储空间,所以空间复杂度是0(1)。

注意,这里存储数据需要一个大小为n的数组,并不是说空间复杂度是 O(n)。因为,这 n 个空间是必须的,无法省掉。所以我们说空间复杂度的时候,是指除了原来的数据存储空间外,算法运行还需要额外的存储空间。

时间复杂度同样也只涉及到栈顶个别数据的操作,因此也是 O(1)。

介绍完了顺序栈,下面来实现一个链栈。

template<class T> LinkedListStack<T>::LinkedListStack()
{
    this -> count = 0;
    this -> head = new LinkedNode;
    this -> head -> next = NULL;
}

template<class T> LinkedListStack<T>::~LinkedListStack()
{
    LinkedNode * ptr, * temp;
    ptr = head;
    while(ptr -> next != NULL){
        temp = ptr -> next;
        ptr -> next = temp -> next;
        delete temp;   
    }
    delete head ; //删除头节点
    this -> head = NULL;
    this -> count = 0;
}

/** 入栈 **/
template<class T> void LinkedListStack<T>::push(const T & data)
{
    LinkedNode * insertPtr = new LinkedNode;
    insertPtr -> data = data;
    insertPtr -> next = this -> head -> next;
    head -> next = insertPtr;
    this -> count ++;
    cout << "push data : " << this -> head -> next -> data << endl;  
}

/** 访问栈顶元素,不删除栈顶元素 **/
template<class T> T LinkedListStack<T>::peek()
{
    if(this -> count == 0 || this -> head -> next == NULL){
        cout << " stack is empty, peek fail"<< endl;
        return NULL;
    }
    else{
        return this -> head -> next -> data;
    }
}

/** 出栈,删除栈顶元素 **/
template<class T> T LinkedListStack<T>::pop()
{
    if(this -> count == 0 || this -> head -> next == NULL){
        cout << " stack is empty, pop fail"<< endl;
        return NULL;
    }
    else{
        LinkedNode * temp = this -> head -> next;
        this -> head -> next = temp -> next;
        T data = temp -> data;
        delete temp;
        this -> count --;
        return  data;
    }

}

/** 返回栈的大小 **/
template<class T> int LinkedListStack<T>::size() const
{
    return this -> count;
}             

栈的应用
1.栈在函数调用中的应用:
栈在软件工程中有很多实用场景。作为一个比较基础的数据结构,其中有一个特别常见的应用场景:函数调用栈。

操作系统会给每个线程分配一块独立的内存空间,这块内存被组织成「栈」这种结构,用来存储函数调用的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

在编译器底层,函数调用可以理解为一个过程,这是软件中很重要的一种抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现某个功能。然后,可以在程序中不同的地方调用这个函数。在程序底层,过程调用机制就是使用栈数据结构来实现的。

图:栈帧结构

栈一般向低地址方向增长,而栈指针 %rsp 指向栈顶元素。可以用 push 和 pop 来存入和取出数据。将栈指针减小一个适当的量可以为没有指定初始值的数据在栈上分配空间。类似的也可以通过增加栈指针来释放空间。

在程序底层实现函数调用的操作叫做转移控制,转移控制正是通过栈来实现的。例如,控制从函数 P 转移到函数 Q ,只需要简单地将程序计数器 PC 设置为 Q 的代码的起始位置(即在函数 P 转移到 函数 Q 的那个位置后面将函数 Q 的参数压入栈)。不过,当稍后从 Q 返回的时候,处理器必须记录好它需要继续 P 的执行的代码位置。一般机器底层是通过call Q 调用过程 Q 来记录。该指令会把地址A压入栈中,并将PC 设置为 Q 的起始地址。压入的地址A被称为返回地址,是紧跟着call指令后面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。call指令有一个目标,即指明被调用过程其实的指令地址。

下面写一段比较简单的代码,带你理解一下函数调用栈具体如何操作的。

void fun(){
    printf("success");
    exit(1);
}
int func1(int a, int b){
    int *p = &a;
    p--;
    *p = (int)fun;
    int c = 0xcccc;
    return c;
}
int main(){
    int a = 0xaaaa;
    int b = 0xbbbb;
    func1(a,b);
    printf("just for fun!");
    return 0;
}

执行main(),下图展示了函数栈的调用过程。

从 main()第一条语句开始向下执行,执行到 func1 函数时,将 func1 的参数 a ,b 压入栈, 然后保存 main 函数里执行 func1 下一条指令的地址(即将此地址入栈),以便于执行完 func1 能够返回到该地址,继续执行。然后开辟 func1 函数的栈帧,先保存 main 中的ebp(ebp 指的是帧指针,永远指向一个栈的顶部),以便于执行完 func1 之后恢复 ebp ,即 ebp 进栈。然后,执行 func1 函数,执行完之后将消除栈帧结构,即将 ebp 出栈。返回main函数继续执行。当main 执行完,只返回一个值,所以,栈帧也就全部释放了。就这样利用栈的特性实现了函数调用。以上过程解释稍有省略,只描述了栈帧的调用。

栈在表达式求值:
编译器如何利用表达式来进行求值,我们将算术表达式简化为只有加减乘除的四则运算。实际上,编译器是通过两个栈来实现的。其中一个保存操作数的栈,另一个保存运算符的栈。我们正常的表达式一般是中缀表达式,例如:1+23/(4+21)-4;但是利用计算机进行运行时,计算机不喜欢这种形式,它更喜欢后缀表达式,1 2 3 * 2 1 * 4 + / 4 -;其实仔细分析一下,后缀表达式正好可以用来实现表达式求值,在后缀表达式中,遇到两个操作数和一个符号就进行运算并得到运算结果,压入栈中。

表达式求值算法描述:

1.从左向右遍历表达式,遇到数字就放到操作数栈。

2.当遇到运算符时,与运算符栈的栈顶元素进行比较,如果该运算符优先级比栈顶元素高,则将当前运算符入栈,如果低,则取栈顶运算符,同时取出两个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。

上面算法过程可以简化为将中缀表达式转化为后缀表达式,然后进行运算。
具体实现如下:

void GetExpress(Stack * OPTR, Stack * OPND)
{
    char ch;
    while ((ch = getchar ()) != EOF) {
        if (IsDigit (ch)) {
            PushStack (OPND, ch);
        }
        else if (ch == '(')
            PushStack (OPTR, ch);
        else if (ch == ')') {
            while (!IsStackEmpty(OPTR)) {
                PopStack (OPTR, op);
                if (op == ')')
                    break;
                PopStack (OPND, num2);
                PopStack (OPND, num1);
                res = Calc (num1, num2, op);
                PushStack (OPND, res);
            }
        }
        else if (ch == '+' || ch == '-'
            || ch == '*' || ch == '/') {
            while (!IsStackEmpty (OPTR) && GetTop (OPTR)!='(' && GetTop (OPTR)>ch) {
                PopStack (OPTR, op);
                PopStack (OPND, num2);
                PopStack (OPND, num1);
                res = Calc (num1, num2, op);
                PushStack (OPND, res);
            }
            if (IsStackEmpty (OPTR) || GetTop(OPTR)=='(')
                PushStack (OPTR, ch);
        }
    }
}
// 当表达式输入完成后,需要对OPTR栈和OPND中的元素进行运算
int GetValue(Stack * OPTR, Stack * OPND)
{
    while (!IsStackEmpty (OPTR)) {
        PopStack (OPTR, op);
        PopStack (OPND, num2);
        PopStack (OPND, num1);
        res = Calc (num1, num2, op);
        PushStack (OPND, res);
    }
    // 最后的操作数栈OPND栈顶元素即是表达式的值
    return GetTop(OPND);
}

栈还用很多应用,如迷宫的设计,浏览器的前进和后退功能。。。都可以借助栈来实现。鉴于篇幅有限,这里不一一详述,先给大家提供一个思考的方向。日后碰到类似的问题均可以找到合适的解决办法。

在这里插入图片描述
扫一扫,关注我

或者搜索『starichat』关注我,获取更多内容。

如果觉得内容对你有帮助,欢迎点赞,转发或者留言与我探讨。谢谢。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值