数据结构篇——栈的操作实现(顺序栈、链栈)!

一:前言

对于栈的操作,虽不及其他数据结构一样多,但是栈的实际应用却是十分广泛。比如在我们进行代码编写的编译器中,对于函数调用、递归操作、表达式求值以及编译器的括号匹配等问题均是通过反复的入栈和出栈操作进行控制的。栈结构在计算机科学的历史上,地位是举重若轻的,值得我们好好进行研究!

在顺序栈和链栈的实现中,我总是将top指针指向栈的下一位置的,这样做是为了以下优点考虑:

1. 简化入栈操作

  • 减少指针调整:当top指针指向入栈元素的下一个位置时,新元素可以直接存储在top指针当前指向的位置,然后top指针简单地递增(或增加相应的偏移量)以指向下一个空位置。这样,在入栈操作中就不需要额外调整top指针来指向新元素的位置,因为新元素已经存储在了top指针原本指向的下一个位置。
  • 避免覆盖风险:如果top指针指向入栈元素的位置,那么在入栈前需要先确保该位置是空的,否则可能会覆盖原有数据。而将top指针指向下一个位置则自然避免了这种风险,因为该位置在入栈前肯定是空的。

2. 便于判断栈空和栈满

  • 栈空判断:当top指针指向入栈元素的下一个位置时,如果栈为空,则top指针和栈底指针(base指针)会指向相同的位置或相邻的位置(取决于具体实现)。这样,通过比较top指针和base指针就可以很容易地判断栈是否为空。
  • 栈满判断(针对动态分配的顺序栈):在动态分配的顺序栈中,如果top指针指向入栈元素的下一个位置,那么当栈满时,top指针和栈的当前容量边界(通常是base指针加上栈的当前分配大小)之间将没有空余位置。这样,就可以通过比较top指针和容量边界来判断栈是否已满,从而决定是否需要进行扩容操作。

如图:

二:定义

栈其实算是属于操作受限的线性表,规定栈只能在栈顶进行插入与删除,相较于其他如数组与链表这样的线性表,栈的操作受到了一定的限制。数据结构中的栈是一种遵循后进先出的结构,又被称为LIFO(last in first out)结构。在这种数据结构中,元素的添加(称为“入栈”或“压栈”)和移除(称为“出栈”或“弹栈”)操作都仅发生在栈的同一端,这一端被称为栈顶,而另一端则被称为栈底。栈底是栈中固定不变的一端,栈顶是随着元素的加入和移除而动态变化的一端。可以理解为栈相当于一个放在地上的水缸,栈底是挨着地面的水缸底部,而栈顶是水缸中水的水位线,只能在水缸的缸口进行添水和取水。

说明:在实际的计算机内存分配中,计算机对与栈的内存分配实际上是从高地址到低地址的方向进行分配的,这与堆的内存分配方向是相向的,具体原因在这不做赘述,感兴趣可以自行搜索。但是在我们日常的操作中,我们习惯性的将栈进行从下到上的方向为操作方向,因为这符合我们大部分人的实际逻辑,在实际问题上其实并无影响。在这里我们将采取从下到上的方向进行操作。

下图为计算机为栈分配内存空间的逻辑,这只是一个简略图,实际情况要比这复杂的多,主要是为了说明系统在为栈分配空间时,其所分配的内存空间的地址是由高到低的,其栈底是在高地址上。

下图是我们编写代码所使用的逻辑 ,我们通常将栈顶视为栈的开始或结束,并假设栈是从下(逻辑上较低的位置)向上(逻辑上较高的位置)增长的。

注意:在实际编写代码实现栈时,我们的逻辑通常是将栈从低到高进行增长的,虽然这与物理内存的实际分配方向是相反的。但是重要的是要理解这两种增长方向是在不同的层面上讨论的:一种是物理内存分配的方向,另一种是栈数据结构在逻辑上的增长方向。 所以我们实现数据结构时,主要是要从我们的逻辑出发点,而不是从物理内存的逻辑出发的。

三:栈的结构

  • 栈顶与栈底:栈的两端分别称为栈顶(Top)和栈底(Bottom/Base)。栈顶是元素入栈和出栈的一端,也是最后一个加入的元素所在的位置;栈底是栈的固定一端,通常不直接访问。
  • 元素顺序:栈中的元素按照它们被加入的顺序排列,但是只能从栈顶进行元素的添加(入栈)和移除(出栈)操作。

四:顺序栈(数组实现)

在前言中已经说过,接下来的顺序栈和链栈的top指针总是指向入栈元素的下一位置。

对于操作的实现以及相关步骤的注释我放在了下附的完整代码中,此外以下几点需特殊注意。

注意:

①Stack结构定义中,可将base定义成一个静态数组,例如int base[MAXSIZE];也可定义为一个指针进行手动分配内存,取决于个人操作。

②在获取栈内元素个数时,我将栈顶与栈底指针进行相间得到元素个数,当两个指针相减时,它们并不直接得到两个指针在内存中的地址差值(即字节数)。相反,它们得到的是一个差值,这个差值代表了两个指针之间有多少个指针所指向的类型的元素。这在C与C++的指针操作中,是一个非常便利的特性。

③在入栈和出栈操作中我分别使用了*s.top++ = data;和data = *--s.top;操作。在这里,自增或自减运算符的优先级是始终低于解引用运算符*的,我们不能通过优先级来想象这两个表达式究竟是该先解引用还是先进行后递增还是前递减。他们之间得优先级差异主要体现在表达式求值顺序上,而不是与解引用运算符优先级的对比上。

  • 前自增(++x)会先增加变量的值,然后在表达式中使用这个新值。
  • 后自增(x++)会先在表达式中使用变量的旧值,然后再增加变量的值。

所以入栈会先解引用后递增,出栈则先递减后解引用。 

*④在结构定义中,我是为base手动进行的空间分配,在销毁操作中,对已分配的数组大小的内存,要用delete[] s.base;来对整个数组进行释放,而不是使用delete s.base来进行单一对象的释放。因为非常容易遗忘或搞错,所以这一点非常重要,需要着重记忆。

完整代码:


#include <iostream>
#define MAXSIZE 100            //栈内最大元素数量
using namespace std;

typedef struct Stack
{
    int *top;           //栈顶指针
    int *base;          //设置为指针,便于动态分配空间,更为方便
    int maxStackSize;   //栈内最大元素数量
} Stack;

// 栈的初始化
void initialStack(Stack &s)
{
    s.base = new int[MAXSIZE];   //为栈手动分配空间,base指向栈底
    s.top = s.base;              //初始化栈顶指向栈底,表示空栈
    s.maxStackSize = MAXSIZE;    
}

// 判断栈是否为空栈(为了方便后续进行输出验证,我将输出操作放在了该函数中,一下进行判定的函数局势如此)
bool emptyStack(Stack s)
{
    if (s.top == s.base)          
    {
        cout << "栈为空!!!" << endl;
        return true;
    }
    else
    {
        cout << "栈不为空!!!" << endl;
        return false;
    }
}

// 判断栈是否为满栈
bool fullStack(Stack s)
{
    return s.top - s.base == s.maxStackSize; //指针进行相减,会得到数组中元素的个数
}

// 得到栈的长度(栈内元素个数)
int getStackLength(Stack s)
{
    return s.top - s.base;   //指针相减,得到数组中元素个数
}

// 入栈
void push(Stack &s)
{
    if (s.top - s.base == s.maxStackSize)
    {
        cout << "栈已经满了,入栈失败!!!" << endl;
        return;
    }
    int data = 0;
    cout << "请输入准备入栈的数据:";
    cin >> data;
    *s.top++ = data;   //先对top指针进行解引用,后将数据进行赋值,在对top指针进行递增操作,使其指向下一个地址
}

// 出栈并返回出栈元素
int pop(Stack &s)
{
    if (s.top == s.base)
    {
        cout << "栈为空,出栈失败!!!";
        return -1;
    }
    int data = 0;
    data = *--s.top;  //先将top指针递减,再进行解引用,得到栈顶数据
    return data;
}

// 清空栈(并不销毁,只是让栈内无元素)
void clearStack(Stack &s)
{
    s.top = s.base;
}

// 销毁栈
void destroyStack(Stack &s)
{
    delete []s.base;   //尤其要注意,前面为s.base分配的为一个数组空间,需要对数组进行删除,而不是用delete s.base进行单个对象的释放
    s.maxStackSize = 0;
    s.base = s.top = nullptr;   //释放空间后将指针置为空
}

int main()
{
    Stack s;
    initialStack(s);
    emptyStack(s);
    cout << "请输入待入栈元素个数:";
    int numbers = 0;
    cin >> numbers;
    for (int i = 0; i != numbers; ++i)
    {
        push(s);
    }
    emptyStack(s);
    cout << "当前栈内元素个数为:" << getStackLength(s) << endl;

    cout << "请输入待弹出元素个数:";
    int n = 0;
    cin >> n;
    for (int i = 0; i != n; ++i)
    {
        cout << pop(s) << " ";
    }
    cout << endl;
    emptyStack(s);

    return 0;
}

五:链栈(链表实现)

链栈的操作实际上和单链表的操作并无两样,甚至由于栈结构可操作空间的一些限制,使得链栈的操作比单链表还要简单些。

①链栈有两种逻辑上的实现方式,一种是非常规方法,一种为常规方法,我下附的代码实现使用的是常规方式。

上图为非常规逻辑下的链栈图,每次进栈后的结点的next域始终指向下一个待入栈的结点。

下图是常规逻辑下的链栈图,每次进栈的结点的next域始终指向前一栈内结点

我使用的是常规实现。之所以这样实现,是因为以下优点:

  1. 符合栈的LIFO原则:这种实现方式使得每次出栈操作都能直接访问到栈顶元素的前一个元素,从而方便地进行后进先出的操作。
  2. 操作简单:入栈时,只需将新节点的next指向当前栈顶节点,然后更新栈顶指针为新节点即可。出栈时,只需更新栈顶指针为当前栈顶节点的next即可。
  3. 易于理解和实现:这是链栈最直观的实现方式。

非常规实现虽然也可以实现链栈,但是其不符合LIFO原则、操作复杂以及效率低下等缺点使得非常规实现方法基本不用考虑。 

完整代码:

#include <iostream>
using namespace std;

typedef struct stackNode
{
    int data;            //数据域
    stackNode *next;     //指针域
} *LinkStack;

// 初始化链栈
void initialStack(LinkStack &s)
{
    s = nullptr;         //初始化链栈指针s为空
}

// 判断链栈是否为空
bool emptyStack(LinkStack &s)
{
    if (s == nullptr)
    {
        cout << "该链栈为空!!!" << endl;
        return true;
    }
    else
    {
        cout << "该链栈不为空!!!" << endl;
        return false;
    }
}

// 入栈
void push(LinkStack &s)
{
    LinkStack newNode = new stackNode;
    cout << "请输入待入栈数据:";
    int data = 0;
    cin >> data;
    newNode->data = data;
    if (s == nullptr)
        s = newNode;
    else
    {
        newNode->next = s;
        s = newNode;
    }
}

// 出栈
void pop(LinkStack &s)
{
    LinkStack temp = s->next;   //用临时指针将栈的下一个地址进行记录
    delete s;                   //由于上一步操作,在删除栈顶指针s前,下一空间地址也已保存,不会造成地址丢失
    s = temp;     //更新指针
}

// 获取栈顶元素
int getTopData(LinkStack &s)
{
    return s->data;      //获取栈顶指针所指向数据
}

// 销毁链栈
void destroyStack(LinkStack &s)
{
    LinkStack temp = s->next;   
    while (s)
    {
        delete s;
        s = temp;
        temp = temp->next;
    }
}

int main()
{
    LinkStack s;
    initialStack(s);
    emptyStack(s);

    for (int i = 0; i != 5; ++i)
    {
        push(s);
    }
    emptyStack(s);
    for (int i = 0; i != 5; ++i)
    {
        cout << getTopData(s) << " ";
        pop(s);
    }

    cout << endl;
    emptyStack(s);

    return 0;
}

六:顺序栈与链栈的有缺点及适用场景

顺序栈

优点
  1. 空间利用率高:顺序栈采用连续的存储空间来存储元素,可以利用数组的空间紧凑特性,使得空间利用率比较高。
  2. 插入和删除操作方便:顺序栈的插入和删除操作都是在栈顶进行的,因此操作的时间复杂度为O(1),比较方便快捷。
  3. 实现简单:顺序栈的实现相对简单,通常使用数组作为底层数据结构,易于理解和实现。
缺点
  1. 栈溢出问题:由于顺序栈的存储空间是有限的,当入栈的元素超过栈的容量时,就会发生栈溢出问题,需要采取相应的措施进行处理。
  2. 空间浪费:当栈的容量不固定时,必须设置栈的长度以使其可以容纳更多的元素,这可能会导致部分空间未被有效利用,造成空间浪费。
使用场景
  • 当数据量不是特别大,且不需要频繁地进行扩容或缩容操作时,顺序栈是一个较好的选择。

链栈

优点
  1. 动态扩容:链栈的容量可以动态调整,不受固定大小的限制,因此不会发生栈溢出问题(除非内存不足,但也几乎不会出现这样的情况)。
  2. 灵活性高:链栈的插入和删除操作同样在栈顶进行,且由于是基于链表实现的,因此具有较高的灵活性。
缺点
  1. 存储空间消耗较大:链栈需要额外的空间来存储指针,使得存储空间的消耗相对于顺序栈来说较大。
  2. 访问元素的效率较低:由于链栈是基于链表实现的,访问元素的时间复杂度为O(n),效率较低。
使用场景
  • 当数据量可能非常大,或者栈的容量需要频繁变化时,链栈因其动态扩容的特性而更加适合。

七:总结(必看!!!)

①在这里提到上篇链表文章中没有提到的,就是实现数据结构时,我们主要是根据数据的逻辑关系和操作需求来设计数据结构,而不是直接依赖于实际的物理内存逻辑。通俗点来讲就是,例如我们在设计链表或栈等数据结构时,我们不用考虑它们是如何在实际的物理内存中是怎样存储的,这种事情应该交给计算机,而我们只需要考虑在实现这些数据结构的操作时,怎样用我们人类的逻辑将他们串联起来,只要我们逻辑上没有问题,其他的就不用管了。

②栈的实现也不是唯一性的,每个人都有自己的实现方法,只要别脱离栈的内核就行。还是那句话,理解才是最主要的。

③与链表一样,实现起来感到困惑时,一定要画图辅助理解。

④栈的实现也是基于数组和链表的,如果链表方面还有疑问。点击下方链接https://blog.csdn.net/jjq20020211/article/details/140686260?spm=1001.2014.3001.5501

④大家加油!

  • 9
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值