栈结构解析及其应用

导言

随着生活水平的不断提高,越来越多的轿车走进千家万户,不过这也带来了一个严重的问题——停车位的寻找变得困难,因此在生活中我们经常会遇到把车停在不应该停的位置,导致半夜接到电话要求挪车或者收了罚单。现在我们来想象一个情景,我要在一个只有一个出口的窄巷子停车,那么停在内部的车想要开出来,就必须等在最外面的车开走,新的车停进来,只能停在窄巷子的最外面,最里面的车想要开出来就必须让其他所有的车都开走。

这真是一种我们很不愿意见到的情景,好在现实中司机一般不会做这种事情。如果我们把这个窄巷子抽象成一个线性表,车当做表中的元素,我们会发现这个线性表只能对表尾操作,放入新的元素就必须从表尾放入,由于尾部的元素把表的唯一出口堵死了,因此想要把表中的元素拿出,就只能拿出表尾,即最后一个元素。那么这种特殊的顺序表就是一种新的数据结构——栈,它的特点是先进后出,后进先出。栈在计算机相关领域中使用广泛,举个大家熟悉的例子,例如浏览器的后退功能,同个这个按键,我们可以查看单个网页的页面之前查看过的连接,而且这个按键的操作也是单向的,后查看的链接会被先查看。

什么是栈?

栈(stack)又名堆栈,它是一种运算受限的线性表,受限于该结构仅能在表尾进行元素的插入和删除操作。首先栈本质上还是一个线性表,只是有一些操作上较为特殊,栈中的元素具有仍然具有线性关系。在允许进行插入和删除的一段被称之为栈顶,表的另一端被称为栈底,若在栈中没有任何元素,栈就被称为空栈,栈结构的插入操作被称为压栈,删除操作被称为退栈出栈。栈最鲜明的特点就是先进后出,后进先出,出栈的元素一定是位于栈顶的元素,在栈顶的元素出栈之后,下一个元素就成为新的栈顶,当栈底的元素执行出栈操作之后栈就成为了空栈。

栈的抽象数据类型

ADT Stack
{
    Data:
        D = {ai | 1 ≤ i ≤ n, n ≥ 0, ai 为 ElemType 类型}    //同线性表
    Relation:
        R = { <ai,ai+1> | ai,ai+1 ∈ D, i = 1, i ∈ (0,n)}    //同线性表
    Operation:
        InitStack(&s);    //初始化栈,开辟一个空间给栈 s
        StackEmpty(*s);    //判断栈是否为空栈,若为空栈返回 true,否则返回 false
        Push(&s,e);    //进栈操作,将元素 e 加入栈结构中并使其作为栈顶
        Pop(&s,&e);    //出栈操作,将位于栈顶的元素删除,并赋值给变量 e
        GetTop(s,&e);    //取栈顶操作,若栈不为空栈,返回栈顶元素并赋值给变量 e
        ClearStack(&s);    //清空栈,将栈中的所有元素清空,即将栈变为空栈
        DestroyStack(&s);    //销毁栈,将释放栈的空间
}

顺序栈及其基本操作

顺序栈

栈是一种特殊的线性表,也自然可以使用顺序存储结构来实现。在 C\C++ 中,我们对于顺序存储往往使用数组来描述,因此我们需要为一个数组选择栈底和栈顶,为了方便描述空栈判定和栈满判定,我们使用下标为 0 的位置作为栈底,当栈顶的下标为数组元素上限时即为栈满,为了时刻定位栈顶的位置,需要定义一个栈顶指针作为游标来辅助。

顺序栈的结构体定义

#define MAXSIZE 100
typedef struct
{
    ElemType data[MAXSIZE];
    int top;    //栈顶指针
}SqStack;

初始化栈

为一个新建立的栈 s 分配足够的空间,由于空栈没有任何元素,因此栈顶指针将初始化为 -1。

void InitStack(SqStack s)
{
    s = new SqStack;
    s->top = -1;    //栈顶指针将初始化为 -1,表示没有任何元素
}

空栈判断

某个结构为空一直是一个显然而敏感的状态,如果不妥善处理就会出现严重的异常,就例如对空栈执行出栈操作,就会出现非法读取的情况。因此虽然空栈判断的代码简单,但是值得我们重视。函数在栈为空栈时返回 true,反之返回 false。

bool StackEmpty(SqStack *s)
{
    if(s->top == -1)
    {
        return true;
    }
    return false;
}

进栈操作

由于栈是一种操作受限的线性表,因此进栈操作是其核心操作之一。进栈的关键在于只能在表尾进行插入,并且当栈的空间为满的时候,不能入栈。函数将在栈不为满栈的情况下,在栈顶指针 top 处插入元素 e 并使其自增 1,插入成功返回 true,否则返回 false。时间复杂度 O(1)。

bool Push(SqStack &s,ElemType e)
{
    if(s->top == MAXSIZE - 1)    //判断是否栈满
    {
        return false;
    }
    s->data[s->top++] = e;    //入栈
    return true;
}

出栈操作

同进栈,出栈也是很重要的操作,出栈的关键在于只能在表尾进行插入,并且当栈的空间为空的时候,不能出栈。函数将在栈不为空栈的情况下,将位于栈顶指针 top 处的元素出栈并赋值给变量 e ,top 需要并使其自减 1,退栈成功返回 true,否则返回 false。时间复杂度 O(1)。

bool Pop(SqStack &s,ElemType e)
{
    if(StackEmpty(s))    //判断是否为空栈
    {
        return false;
    }
    e = s->data[s->top--];    //退栈
    return true;
}

取栈顶操作

取栈顶操作与出栈操作不同的是,取栈顶操作只需把栈顶元素赋值给变量 e,无需对栈进行修改。时间复杂度 O(1)。

bool GetTop(SqStack &s,ElemType e)
{
    if(StackEmpty(s))    //判断是否为空栈
    {
        return false;
    }
    e = s->data[s->top];    //取栈顶
    return true;
}

链栈及其基本操作

链栈

当栈使用链式存储结构来存储时,可以建立单链表来描述,显然以链表的表头结点作为栈顶是最方便的。使用连式存储结构的优点在于,栈的空间在一般情况下不需要考虑上限。对于链栈来说,我们可以不设置头结点。

链栈的结构体定义

typedef struct StackNode
{
    ElemType data;
    struct StackNode *next;
}Node,*Stack;

初始化栈

初始化的操作是为了构造一个空栈,在不设置头结点的情况下,我们把栈顶指针搞成 NULL 即可。

bool InitStack(Stack &s)
{
    s = NULL;
    return true;
}

空栈判断

某个结构为空一直是一个显然而敏感的状态,如果不妥善处理就会出现严重的异常,就例如对空栈执行出栈操作,就会出现非法读取的情况。因此虽然空栈判断的代码简单,对于链栈值得我们重视。函数在栈为空栈时返回 true,反之返回 false。

bool StackEmpty(Stack *s)
{
    if(s == NULL)
    {
        return true;
    }
    return false;
}

进栈操作

对于链栈的进栈操作,我们不需要判断是否出现栈满的情况,只需要用头插法引入新结点即可,插入成功返回 true,否则返回 false。时间复杂度 O(1)。

bool Push(Stack &s,ElemType e)
{
    Stack ptr = new Node;    //为新结点申请空间
    
    ptr->next = s;    //修改新结点的后继为 s 结点,入栈
    ptr->data = e;
    s = ptr;    //修改栈顶为 ptr
    return true;
}

出栈操作

同顺序栈,当栈的空间为空的时候,不能出栈,函数将在栈不为空栈的情况下,需要把栈顶结点的空间释放掉,退栈成功返回 true,否则返回 false。时间复杂度 O(1)。

bool Pop(Stack &s,ElemType e)
{
    Stack ptr;

    if(StackEmpty(s))    //判断是否为空栈
    {
        return false;
    }
    e = s->data;    //将栈顶元素赋值给 e
    ptr = S;    //拷贝栈顶元素
    S = S->next;    //退栈
    delete ptr;    //释放原栈顶元素结点的空间
    return true;
}

取栈顶操作

当栈非空时,把栈顶元素赋值给变量 e,时间复杂度 O(1)。

bool GetTop(SqStack &s,ElemType e)
{
    if(StackEmpty(s))    //判断是否为空栈
    {
        return false;
    }
    e = s->data;    //取栈顶
    return true;
}

双端栈

实现目标

复杂的操作由基本操作组合而成

我们这么去理解,假设我们已经定义了两个栈,开辟了一定的空间,那么会不会出现一个栈满了,而另一个栈还有很多空间呢?那么我们在这个时候就很希望能够让第一个栈使用第二个栈的空间,从理论上讲,这样是完全可行的,因为我们只需要让这两个栈能够分别找到自己的栈顶和栈底即可。例如在一个数组中,我们可以让数组的始端和末端分别为两个栈的栈底,再通过操作游标来实现对栈顶的描述。对于栈满的判断呢?只要两个栈的栈顶不见面,栈就不为满栈。


代码实现

建立双端栈

Stack CreateStack(int MaxSize)    //建立双端栈
{
    Stack sak = (Stack)malloc(sizeof(struct SNode));
    sak->MaxSize = MaxSize;
    sak->Data = (ElementType*)malloc(MaxSize * sizeof(ElementType));
    sak->Top1 = -1;
    sak->Top2 = MaxSize;

    return sak;
}

入栈操作

bool Push(Stack S, ElementType X, int Tag)    //入栈
{
    if (S->Top2 - 1 == S->Top1)
    {
        printf("Stack Full\n");
        return false;
    }

    if (Tag == 1)
    {
        S->Data[++S->Top1] = X;
    }
    else
    {
        S->Data[--S->Top2] = X;
    }
    return true;
}

出栈操作

ElementType Pop(Stack S, int Tag)    //出栈
{
    if (Tag == 1)
    {
        if (S->Top1 < 0)
        {
            printf("Stack %d Empty\n",Tag);
            return ERROR;
        }
        else
        {
            return S->Data[S->Top1--];
        }
    }
    else
    {
        if (S->Top2 == S->MaxSize)
        {
            printf("Stack %d Empty\n",Tag);
            return ERROR;
        }
        else
        {
            return S->Data[S->Top2++];
        }
    }
}

栈的应用-符号配对

应用情景

情景分析

由于我们只关注表达式的括号是否是成双成对的,因此只需要获取我们所需即可。当我获取第一个括号时,虽然后面可能会有贼多括号,但是我们只继续接受下一个括号,若下一个括号仍然为左括号,那么这个括号需要配对的优先级是高于第一个左括号的。继续读取,若下一个括号为右括号,就拿来和配对优先级较高的第二个括号比对,若成功配对则消解第二个括号,而第一个括号需要配对的优先级就提升了。经过分析我们发现,使用栈结构来描述这个过程极为合适。

伪代码

代码实现

#include <iostream>
#include <stack>
#include <string>
using namespace std;
int main()
{
    string equation;
    stack<char> brackets;   //存储被配对的左括号
    int flag = 0;

    cin >> equation;
    for (int i = 0; equation[i] != 0; i++)
    {
        if (equation[i] == '(' || equation[i] == '[' || equation[i] == '{')    //第 i 个字符是左括号
        {
            brackets.push(equation[i]);
        }
        else if (brackets.empty() && (equation[i] == ')' || equation[i] == ']' || equation[i] == '}'))
        {
            flag = 1;                                                     //第 i 个字符是右括号但栈是空栈
            break;
        }
        else if (equation[i] == ')' && brackets.top() == '(')    //栈顶括号与右括号配对
        {
            brackets.pop();
        }
        else if (equation[i] == ']' && brackets.top() == '[')
        {
            brackets.pop();
        }
        else if (equation[i] == '}' && brackets.top() == '{')
        {
            brackets.pop();
        }
    }

    if (flag == 1)    //输出配对结果
    {
        cout << "no";
    }
    else if (brackets.empty() == true)
    {
        cout << "yes";
    }
    else
    {
        cout << brackets.top() << "\n" << "no";
    }
    return 0;
}

栈的应用-逆波兰式的转换

逆波兰式

众所周知,对于一个算式而言,不同的运算符有优先级之分,例如“先乘除,后加减”,如果是我们人工进行计算的话,可以用肉眼观察出算式的运算顺序进行计算。可是对于计算机而言,如果是一个一个读取算式进行计算的话,可能不能算出我们想要的答案,因为这么做是没有优先级可言的。想要让计算机实现考虑优先级的算式计算,我们首先要先找到一种算式的描述方式,这种方式不需要考虑运算符优先级。
逆波兰式(Reverse Polish notation,RPN,或逆波兰记法),也叫后缀表达式(将运算符写在操作数之后的表达式),是波兰逻辑学家卢卡西维奇提出的,例如“2 + 3 * (7 - 4) + 8 / 4”这样一个表达式,它对应的后缀表达式是“2 3 7 4 - * + 8 4 / +”,这种表达式的计算方法是遇到运算符就拿前面的两个数字来计算,用这个数字替换掉计算的两个数字和运算符,直到得出答案。

应用情景

伪代码

代码实现

#include <iostream>
#include <stack>
#include <queue>
#include <string>
#include <map>
using namespace std;

int main()
{
	string str;
	stack<char> sign;    //存储符号
	queue<char> line;    //存储转换好的逆波兰式,便于后续实现计算
	map<char, int> priority;
	priority['('] = 3;    //为符号设置优先级
	priority[')'] = 3;
	priority['*'] = 2;
	priority['/'] = 2;
	priority['+'] = 1;
	priority['-'] = 1;
	int flag = 0;

	cin >> str;
	for (int i = 0; i < str.size(); i++)
	{                                                                                           //读取到数字
		if (((i == 0 || str[i - 1] == '(') && (str[i] == '+' || str[i] == '-')) || (str[i] >= '0' && str[i] <= '9'))
		{
			line.push('#');
			if (str[i] != '+')
			{
				line.push(str[i]);
			}
			while ((str[i + 1] >= '0' && str[i + 1] <= '9') || str[i + 1] == '.')
			{
				line.push(str[++i]);
			}
		}
		else    //读取到运算符
		{
			if (str[i] == ')')    //运算符是右括号
			{
				while (!sign.empty() && sign.top() != '(')    //左括号之后的运算符全部出栈
				{
					line.push('#');
					line.push(sign.top());
					sign.pop();
				}
				sign.pop();
				continue;
			}
			else
			{
				while (!sign.empty() && sign.top() != '(' && priority[str[i]] <= priority[sign.top()])
				{
					line.push('#');
					line.push(sign.top());
					sign.pop();
				}
			}
			sign.push(str[i]);
		}
	}
	while (!sign.empty())    //将栈内剩余的符号出栈
	{
		line.push('#');
		line.push(sign.top());
		sign.pop();
	}

	while (!line.empty())
	{
		if (flag == 0 && line.front() == '#')
		{
			flag++;
		}
		else if(line.front() == '#')
		{
			cout << ' ';
		}
		else
		{
			cout << line.front();
		}
		line.pop();
	}
	return 0;
}

迷宫寻路(深度优先)

左转博客——栈和队列应用:迷宫问题

八皇后问题(栈实现)

左转我另一篇博客八皇后问题——回溯法思想运用

参考资料

《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构教程》—— 李春葆 主编,清华大学出版社
《数据结构与算法》—— 王曙燕 主编,人民邮电出版社
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社

posted @ 2020-03-22 20:20  乌漆WhiteMoon  阅读(1513)  评论(7编辑  收藏  举报