• 数据结构——栈和队列


    前言

           栈和队列是两种不同的线性数据结构。这两种数据结构相比之前的基础数据结构顺序表和链表来说,特点更加突出,实用性也更强。而这两种数据结构的实现也建立在顺序表和链表的基础之上,建议先了解顺序表和链表的原理再学习栈和队列。

    顺序表解析】传送门:http://t.csdn.cn/TZ5mw

    【链表解析】传送门:http://t.csdn.cn/CRTmg


    1 栈

    1.1 区分栈与栈区

           可能有些同学在学习内存的时候听说过内存中的栈区。栈区与本文中讨论的栈是有本质区别的。栈区是内存中的一块区域,而栈是一种数据结构。

    1.2 思路引入

           不知道大家有没有去过超市,由于网上购物的兴起,我已经很久没有去过线下的超市了。记得小时候去超市的时候,由于要买的东西比较多,通常会推上一个购物车,这样一来我可以把挑选好的商品放入购物车中,等到结账时再拿出来。

            到结账时,要把商品一件一件的放到收银台上,在这个过程中我们可以发现,我们通常都是先拿位于上方的商品,再拿下方的。这是因为上方商品压住了下方商品,导致我们难以先拿下方商品。而这些商品的相对位置其实与我们将其放入购物车的时间有关,越早放入购物车,商品就越位于下方。因此,后进入购物车的商品,通常先离开购物车,这就是栈的特点。

    1.3 基本思路

           栈(Stack)是一种线性数据结构,可以想象一下把购物车压缩成线状(竖直放置),每个高度上只能放一件商品,如下图。

            通常,我们将最后一个元素的位置称为栈顶,第一个元素的位置称为栈底。如此一来,插入和删除元素都只能在栈顶进行,我们通常把栈的插入称为入栈,删除称为出栈。

            其实与上图类似的图形,在学习基础数据结构时我们就见到过。我们将上图顺时针旋转90度可以得到以下图形。

            这样看是不是清楚多了,这其实就是一个顺序表,而所谓的入栈和出栈其实就是顺序表的尾插和尾删。

    1.4 栈的实现 

           栈的实现可以使用顺序表,也可以使用链表。这里我选用顺序表进行模拟实现。

    1.4.1 接口

           既然是通过顺序表来实现栈,那么模拟的结构也与顺序表相似,区别在于栈有唯一的插入和删除方式,也就没有头插,尾插,随机插入这类说法了。

           由于在栈里只能操作栈顶元素,因此这里多了一个获取栈顶元素的功能,用来访问栈内元素。另外,在使用栈的时候,有时会以栈是否为空作为判断条件,因此多出一个判空功能。学习过顺序表后,下面的实现看上去就非常简单了。

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. #define MAX 100 //最大容量
    7. #define STACKADD 2 //扩容容量
    8. typedef int SDateType;
    9. typedef struct Stack
    10. {
    11. SDateType* data;
    12. int top;
    13. int capacity;
    14. }Stack;
    15. //初始化栈
    16. void StackInit(Stack* ps);
    17. //判空
    18. bool StackEmpty(Stack* ps);
    19. //入栈
    20. void StackPush(Stack* ps, SDateType x);
    21. //出栈
    22. void StackPop(Stack* ps);
    23. //获取栈顶元素
    24. void StackGetTop(Stack* ps, SDateType* d);
    25. //销毁栈
    26. void StackDestroy(Stack* ps);

    1.4.2 初始化与判空

          由于我们是用顺序表来实现栈,那么这里我们就可以参考顺序表的初始化。不同点在于,由于栈顶是栈最后一个元素所在的位置,因此在初始化时,由于栈为空,那么栈顶的位置显然不是0。在这里我们可以将top初始化为-1,那么当第一个元素入栈时,让top自增1,栈顶位置就变成了0,这样就解决了这一问题。

    1. void StackInit(Stack* ps)
    2. {
    3. assert(ps);
    4. ps->data = (SDateType*)malloc(STACKADD * sizeof(SDateType));
    5. if (ps->data == NULL)
    6. {
    7. perror("Init malloc:");
    8. return;
    9. }
    10. ps->top = -1;
    11. ps->capacity = STACKADD;
    12. }

            完成初始化后,再来看判空就非常简单了,因为初始化后的栈不就是一个空栈吗?那么判空的条件就是top等于-1。

    1. bool StackEmpty(Stack* ps)
    2. {
    3. assert(ps);
    4. if (ps->top != -1)
    5. return false;
    6. else
    7. return true;
    8. }

    1.4.3 入栈,出栈与获取栈顶元素

           根据上面的分析,入栈和出栈的操作就是顺序表的尾插和尾删。需要注意点的是栈顶的位置并不是最后一个元素的下一个位置,因此需要在top+1位置插入。过程我就不过多介绍了,不理解的可以先学习顺序表。

    1. void StackPush(Stack* ps, SDateType x)
    2. {
    3. assert(ps);
    4. ps->top++;
    5. if (ps->top == ps->capacity && ps->capacity != MAX)
    6. {
    7. ps->data = (SDateType*)realloc(ps->data, (ps->capacity + STACKADD) * sizeof(SDateType));
    8. if(ps->data==NULL)
    9. {
    10. perror("Push realloc:");
    11. return;
    12. }
    13. }
    14. ps->data[ps->top] = x;
    15. }

     

    1. void StackPop(Stack* ps)
    2. {
    3. assert(ps);
    4. assert(!StackEmpty(ps));
    5. ps->top--;
    6. }

            关于获取栈顶元素,这个操作比较简单,就是通过下标访问数组元素,将栈顶元素值赋给传入的指针变量所指向的变量。

    1. void StackGetTop(Stack* ps, SDateType* d)
    2. {
    3. assert(ps);
    4. assert(!StackEmpty(ps));
    5. *d = ps->data[ps->top];
    6. }

    1.4.4 栈的销毁

           动态开辟的空间使用完毕后都要手动释放,而栈的销毁只要有首元素的地址(栈底地址)即可。

    1. void StackDestroy(Stack* ps)
    2. {
    3. assert(ps);
    4. free(ps->data);
    5. ps->data = NULL;
    6. }

    1.5 疑难杂症

    1.5.1 为何不采用头插和头删

           我在画图时将竖直的栈顺时针旋转90度形成了顺序表,那么可不可以逆时针旋转90度呢?这样一来,我们的入栈和出栈操作就变成了顺序表的头插和头删。而在顺序表中,尾插和尾删的时间复杂度均为O(1),而头插和头删的时间复杂度均为O(n)。这就是入栈和出栈采用尾插和尾删而不是头插和头删的原因。

    1.5.2 为何不采用链表实现 

           与顺序表相比,链表的头插和头删的时间复杂度均为O(1),并且在其它方面链表的效率也不输顺序表。在栈的实现中,链表唯一一点不如顺序表的地方就在于销毁。链表的节点是散布内存各处的,销毁时需要通过遍历逐一释放节点,时间复杂度为O(n)。而顺序表的数据存储在一段连续的空间上,只需要空间的首地址即可一键释放,时间复杂度为O(1)。这就是文章采用顺序表实现栈的原因。

    2 队列 

    2.1 思路引入

           队列(Queue)在生活中其实非常常见,如在食堂排队打饭的队列、在超市收银台排队等待支付的队列,或是等待办理某项业务的队列。这些队列的出现通常是为了控制秩序,提高业务办理的效率。

           数据结构中所说的队列与我们生活中的队列有一个共同点的特点。假设我们把排队的人看成一个一个的节点,排在最前面的人称为队头,最后的人称为队尾。由于排队是为了办理某项业务,那么当队头的人办理完业务之后就可以离开了,称为出队列,而如果有新需要办理该业务的人,则他应该排在队尾,称为入队列

    2.2 基本思路

           根据上面的分析,队列的数据插入和删除的特点为一侧插入,另一侧删除。也就是说,我们可以采用头插加尾删的组合,也可以采用头删加尾插的组合。

           如下图(图中箭头方向为链表节点的指针方向,不是队列朝向)。

    2.3 队列的实现

           队列作为一种线性数据结构,我们同样可以用顺序表或者链表还原。我认为最好理解的实现方式就是采用头删加尾插的链表来实现队列。

    2.3.1 接口

           采用链表来实现,首先要做的就是声明所需的链表节点结构。而对于队列,这里我选择再声明一个Queue结构,可以保存队列的队头节点和队尾节点的地址,这样一来就能通过地址更快速地找到队尾节点而不是遍历链表。

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. typedef int QDataType;
    7. typedef struct QueueNode
    8. {
    9. QDataType data;
    10. struct QueueNode* next;
    11. }QueueNode;
    12. typedef struct Queue
    13. {
    14. QueueNode* front;
    15. QueueNode* rear;
    16. }Queue;
    17. //初始化队列
    18. void QueueInit(Queue* pq);
    19. //判空
    20. bool QueueEmpty(Queue* pq);
    21. //入队
    22. void QueuePush(Queue* pq, QDataType x);
    23. //出队
    24. void QueuePop(Queue* pq);
    25. //取队头元素
    26. void QueueGetFront(Queue* pq, QDataType* d);
    27. //销毁队列
    28. void QueueDestroy(Queue* pq);

    2.3.2 初始化与判空

           我们期望的初始化结果应该是一个空队列,也就是一个没有任何节点的队列,那么我们的初始化就并不需要创建节点,只需要将队列的front指针和rear指针置空即可。

           那么队列的判空条件也就显而易见了,当front指针为空时,该队列即为空队列。代码如下。

    1. void QueueInit(Queue* pq)
    2. {
    3. assert(pq);
    4. pq->front = pq->rear = NULL;
    5. }
    1. bool QueueEmpty(Queue* pq)
    2. {
    3. assert(pq);
    4. if (pq->front == NULL)
    5. return true;
    6. else
    7. return false;
    8. }

    2.3.3 入队列、出队列与获取队头元素

           前面也提到过,我们这里的入队列采用尾插,出队列采用头删,这样的方式与生活最接近,更好理解。

           在入队列时,我们可以通过队尾指针直接访问队尾节点,大大提高了尾插效率,其它与链表的尾插无太大区别,同样需要注意处理链表为空的特殊情况。出队列则与链表头删无区别。这样一来,入队列和出队列的时间复杂度均为O(1)。

           如果采用顺序表来实现队列,将无可避免使用顺序表的头插或者头删,而其时间复杂度将达到O(n)。

    1. void QueuePush(Queue* pq, QDataType x)
    2. {
    3. assert(pq);
    4. if (pq->front == NULL)
    5. {
    6. pq->front = pq->rear = (QueueNode*)malloc(sizeof(QueueNode));
    7. if (pq->front == NULL)
    8. {
    9. perror("Push malloc:");
    10. return;
    11. }
    12. pq->rear->data = x;
    13. pq->rear->next = NULL;
    14. }
    15. else
    16. {
    17. QueueNode* newnode = (QueueNode*)malloc(sizeof(QueueNode));
    18. if (newnode == NULL)
    19. {
    20. perror("Push malloc:");
    21. return;
    22. }
    23. pq->rear->next = newnode;
    24. pq->rear = pq->rear->next;
    25. pq->rear->data = x;
    26. pq->rear->next = NULL;
    27. }
    28. }

    1. void QueuePop(Queue* pq)
    2. {
    3. assert(pq);
    4. assert(!QueueEmpty(pq));
    5. QueueNode* prev = pq->front;
    6. if (pq->front == pq->rear)
    7. pq->rear = NULL;
    8. pq->front = pq->front->next;
    9. prev->next = NULL;
    10. free(prev);
    11. }

           获取队头元素较简单,这里直接看代码。

    1. void QueueGetFront(Queue* pq, QDataType* d)
    2. {
    3. assert(pq);
    4. assert(!QueueEmpty(pq));
    5. *d = pq->front->data;
    6. }

    2.3.4 队列的销毁 

           由于本文使用链表实现队列,故销毁队列与销毁链表的方式一样,通过循环遍历来销毁。

    1. void QueueDestroy(Queue* pq)
    2. {
    3. assert(pq);
    4. QueueNode* cur = pq->front;
    5. while (cur)
    6. {
    7. cur = cur->next;
    8. pq->front->next = NULL;
    9. free(pq->front);
    10. pq->front = cur;
    11. }
    12. pq->rear = NULL;
    13. }

    结束语 

           本篇主要介绍了栈和队列两种数据结构的实现思路,并用顺序表和链表分别模拟实现。栈和队列在部分算法中应用较多,非常有必要学习。如果文章内容有误,欢迎各位大佬指正。

  • 相关阅读:
    Linux系统彻底卸载MySQL数据库
    VBA Regex 正则表达式应用介绍
    word中设置页眉,首页不设置
    Spring Cloud Stream 和SpringBoot整合RocketMq区别和选择
    tensorflow跑手写体实验
    基于JAVA西宁市农副产品物流信息系统计算机毕业设计源码+数据库+lw文档+系统+部署
    基于javaweb的毕业设计毕业论文管理系统(java+ssm+jsp+tomcat+mysql)
    Windows系统使用powershell查找并删除重复文件
    语法基础(函数)
    React基础(叁)———事件处理
  • 原文地址:https://blog.csdn.net/m0_74310998/article/details/130874954