数据结构浅析(四):栈与队列

1.栈

1.1.栈的定义

栈(stack)是限定仅在表尾(栈顶 top)进行插入和删除操作的后进先出的线性表。

push、pop 操作在栈顶进行。

ADT 栈(stack)
Data
    同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
    InitStack(*S):    初始化操作,建立一个空栈S。
    DestroyStack(*S): 若栈存在,则销毁它。
    ClearStack(*S):   将栈清空。
    StackEmpty(S):    若栈为空,返回true,否则返回false。
    GetTop(S, *e):    若栈存在且非空,用e返回S的栈顶元素。
    Push(*S, e):      若栈S存在,插入新元素e到栈S中并成为栈顶元素。
    Pop(*S, *e):      删除栈S中栈顶元素,并用e返回其值。
    StackLength(S):   返回栈S的元素个数。
endADT

1.2. 栈的顺序存储结构及实现

栈的结构定义

/* SElemType类型根据实际情况而定,这里假设为int */
typedef int SElemType;    
typedef struct
{
    SElemType data[MAXSIZE];
    /* 用于栈顶指针 */
    int top;              
}SqStack; 
栈普通3种状态

现在有一个栈,StackSize是5,则栈普通情况、空栈和栈满的情况、空栈和栈满的情况示意图如上

进栈(push):

/* 插入元素e为新的栈顶元素 */
Status Push(SqStack *S, SElemType e)
{
    /* 栈满 */
    if (S->top == MAXSIZE - 1)    
    {
        return ERROR;
    }
    /* 栈顶指针增加一 */
    S->top++;                     
    /* 将新插入元素赋值给栈顶空间 */
    S->data[S->top] = e;          
    
    return OK;
}

出栈(pop):

/* 若栈不空,则删除S的栈顶元素,用e返回其值,
   并返回OK;否则返回ERROR */
Status Pop(SqStack *S, SElemType *e)
{
    if (S->top == -1)
        return ERROR;
    /* 将要删除的栈顶元素赋值给e */
    *e = S->data[S->top];    
    /* 栈顶指针减一 */
    S->top--;  
                  
    return OK;
}

1.3.两栈共享空间

两栈共享空间

数组有两个端点,两个栈有两个栈底,让一个栈的栈底为数组的始端,即下标为0处,另一个栈为数组的末端,即下标为数组长度n-1处。这样,两个栈如果增加元素,就是两端点向中间延伸。(只针对两个具有相同数据类型的栈)

栈1为空时,即top1等于-1时;栈2为空时,即top2等于n时;栈满时,即top1+1==top2时。

两栈共享空间的结构的代码:

/* 两栈共享空间结构 */
typedef struct
{
    SElemType data[MAXSIZE];
    int top1;    /* 栈1栈顶指针 */
    int top2;    /* 栈2栈顶指针 */
} SqDoubleStack;

对于两栈共享空间的push方法,除了要插入元素值参数外,还需要有一个判断是栈1还是栈2的栈号参数stackNumber。插入元素的代码如下:

/* 插入元素e为新的栈顶元素 */
Status Push(SqDoubleStack *S, SElemType e, 
int stackNumber)
{
    /* 栈已满,不能再push新元素了 */
    if (S->top1 + 1 == S->top2)    
        return ERROR;
    /* 栈1有元素进栈 */
    if (stackNumber == 1)          
        /* 若栈1则先top1+1后给数组元素赋值 */
        S->data[++S->top1] = e;    
    /* 栈2有元素进栈 */
    else if (stackNumber == 2)     
        /* 若栈2则先top2-1后给数组元素赋值 */
        S->data[--S->top2] = e;    
        
    return OK;
}

对于两栈共享空间的pop方法,参数就只是判断栈1栈2的参数stackNumber,代码如下:

/* 若栈不空,则删除S的栈顶元素,用e返回其值,
   并返回OK;否则返回ERROR */
Status Pop(SqDoubleStack *S, SElemType *e, int stackNumber)
{
    if (stackNumber == 1)
    {
        /* 说明栈1已经是空栈,溢出 */
        if (S->top1 == -1)
            return ERROR;
        /* 将栈1的栈顶元素出栈 */
        *e = S->data[S->top1--];    
    }
    else if (stackNumber == 2)
    {
        /* 说明栈2已经是空栈,溢出 */
        if (S->top2 == MAXSIZE)
            return ERROR;           
        /* 将栈2的栈顶元素出栈 */
        *e = S->data[S->top2++];    
    }
    
    return OK;
}

1.4.栈的链式存储结构(链栈)及实现

链栈的结构代码如下:

typedef struct StackNode
{
    SElemType data;
    struct StackNode *next;
} StackNode, *LinkStackPtr;

typedef struct LinkStack
{
    LinkStackPtr top;
    int count;
} LinkStack;

进栈:

/* 插入元素e为新的栈顶元素 */
Status Push(LinkStack *S, SElemType e)
{
    LinkStackPtr s 
      = (LinkStackPtr)malloc(sizeof(StackNode));
    s->data = e;
    /* 把当前的栈顶元素赋值给新结点的直接后继,如图中① */
    s->next = S->top;    
    /* 将新的结点s赋值给栈顶指针,如图中② */
    S->top = s;          
    S->count++;
    
    return OK;
}

出栈:

/* 若栈不空,则删除S的栈顶元素,用e返回其值,
   并返回OK;否则返回ERROR */
Status Pop(LinkStack *S, SElemType *e)
{
    LinkStackPtr p;
    if (StackEmpty(*S))
        return ERROR;
    *e = S->top->data;
    /* 将栈顶结点赋值给p,如图③ */
    p = S->top;               
    /* 使得栈顶指针下移一位,指向后一结点,如图④ */
    S->top = S->top->next;    
    /* 释放结点p */
    free(p);                  
    S->count--;
    
    return OK;
}

2.栈的应用--递归

2.1.斐波那契数列(Fibonacci)实现

问:如果兔子在出生两个月后,就有繁殖能力,一对兔子每个月能生出一对小兔子来。假设所有兔都不死,那么一年以后可以繁殖多少对兔子呢?

分析:拿新出生的一对小兔子分析一下:第一个月小兔子没有繁殖能力,所以还是一对;两个月后,生下一对小兔子数共有两对;三个月以后,老兔子又生下一对,因为小兔子还没有繁殖能力,所以一共是三对……

依次类推可以列出下表:

表中数字1,1,2,3,5,8,13……构成了一个序列。这个数列有个十分明显的特点,前面相邻两项之和,构成了后一项

如下图:


对应的数学函数:


打印出前40位的斐波那契数列数:

int main()
{
    int i;
    int a[40];
    a[0] = 0;
    a[1] = 1;
    printf("%d ", a[0]);
    printf("%d ", a[1]);
    for (i = 2; i < 40; i++)
    {
        a[i] = a[i - 1] + a[i - 2];
        printf("%d ", a[i]);
    }
    return 0;
}

其实我们的代码,如果用递归来实现,还可以更简单:

/* 斐波那契的递归函数 */ 
int Fbi(int i)
{
    if (i < 2)
        return i == 0 ? 0 : 1;
    /* 这里Fbi就是函数自己,它在调用自己 */
    return Fbi(i - 1) + Fbi(i - 2);    
}
int main()
{
    int i;
    for (i = 0; i < 40; i++)
        printf("%d ", Fbi(i));
    return  0;
}

相比较迭代的代码,是不是干净很多。
那么这段递归是怎么执行的呢?我们来模拟代码种的 Fbi(i) 函数当 i=5 的执行过程,如下图:

Fbi(5)执行过程
Fbi(5)执行过程

2.2.递归定义

迭代和递归的区别是:
迭代使用的是循环结构,递归使用的是选择结构。递归能使程序的结构更清晰、更简洁、更容易让人理解,从而减少读懂代码的时间。但是大量的递归调用会建立函数的副本,会耗费大量的时间和内存。迭代则不需要反复调用函数和占用额外的内存。

递归与栈有什么关系?
这得从计算机系统的内部说起,前面我们已经看到递归是如何执行它的前行(Fbi(i))和退回(return)阶段的。递归过程退回的顺序是它前行顺序的逆序。在退回过程中,可能要执行某些动作,包括恢复在前行过程中存储起来的某些数据。

这种存储某些数据,并在后面又以存储的逆序恢复这些数据,以提供之后使用的需求,显然很符合栈这样的数据结构,因此,编译器使用栈实现递归就没什么好惊讶的了。

简单的说,就是在前行阶段,对于每一层递归,函数的局部变量、参数值以及返回地址都被压入栈中。在退回阶段,位于栈顶的局部变量、参数值和返回地址被弹出,用于返回调用层次中执行代码的其余部分,也就是恢复了调用的状态。

3.栈的应用--四则运算表达式求值

3.1.后缀(逆波兰)表示法应用

对于“9+(3-1)×3+10÷2”,如果要用后缀表示法应该是什么样子:“9 3 1-3*+102/+”,这样的表达式称为后缀表达式,叫后缀的原因在于所有的符号都是在要运算数字的后面出现。

举例:
后缀表达式:9 3 1-3*+10 2/+

规则:从左到右遍历表达式的每个数字和符号,遇到是数字就进栈,遇到是符号,就将处于栈顶两个数字出栈,进行运算,运算结果进栈,一直到最终获得结果。

1.初始化一个空栈。此栈用来对要运算的数字进出使用;
2.后缀表达式中前三个都是数字,所以9、3、1进栈;



3.接下来是“-”,所以将栈中的1出栈作为减数,3出栈作为被减数,并运算3-1得到2,再将2进栈;
4.接着是数字3进栈;



5.后面是“*”,也就意味着栈中3和2出栈,2与3相乘,得到6,并将6进栈;
6.下面是“+”,所以栈中6和9出栈,9与6相加,得到15,将15进栈;

7.接着是10与2两数字进栈;
8.接下来是符号“/”,因此,栈顶的2与10出栈,10与2相除,得到5,将5进栈;



9.最后一个是符号“+”,所以15与5出栈并相加,得到20,将20进栈,如图4-9-5的左图所示。10.结果是20出栈,栈变为空

很顺利的解决了计算的问题,那么如何让“9+(3-1)×3+10÷2”转化为“9 3 1-3+10 2/+”呢?

3.2.中缀表达式转后缀表达式

“9+(3-1)×3+10÷2”这样平时所用的标准四则运算表达式,因为所有的运算符号都在两数字之间,所以叫做中缀表达式。

中缀表达式“9+(3-1)×3+10÷2”转化为后缀表达式“9 3 1-3*+10 2/+”

规则:从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级不高于栈顶符号(乘除优先加减)则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。

1.初始化一空栈,用来对符号进出栈使用;


2.第一个字符是数字9,输出9,后面是符号“+”,进栈;
3.第三个字符是“(”,依然是符号,因其只是左括号,还未配对,故进栈;
4.第四个字符是数字3,输出,总表达式为93,接着是“-”,进栈;

5.接下来是数字1,输出,总表达式为 9 31,后面是符号“)”,此时,我们需要去匹配此前的“(”,所以栈顶依次出栈,并输出,直到“(”出栈为止。此时左括号上方只有“-”,因此输出“-”。总的输出表达式为 9 3 1-;
6.紧接着是符号“×”,因为此时的栈顶符号为“+”号,优先级低于“×”,因此不输出,“”进栈。接着是数字3,输出,总的表达式为 9 3 1-3;

7.之后是符号“+”,此时当前栈顶元素“”比这个“+”的优先级高,因此栈中元素出栈并输出(没有比“+”号更低的优先级,所以全部出栈),总输出表达式为9 3 1-3+。然后将当前这个符号“+”进栈。也就是说,前6张图的栈底的“+”是指中缀表达式中开头的9后面那个“+”,而图4-9-9左图中的栈底(也是栈顶)的“+”是指“9+(3-1)×3+”中的最后一个“+”;
8.紧接着数字10,输出,总表达式变为9 31-3
+10。后是符号“÷”,所以“/”进栈;

9.最后一个数字2,输出,总的表达式为9 31-3+10 2。如图4-9-10的左图所示。10.因已经到最后,所以将栈中符号全部出栈并输出。最终输出的后缀表达式结果为93 1-3+10 2/+”;


4.队列

4.1.队列定义

队列(queue)是一种先进先出(First In First Out)的线性表,简称FIFO。只允许在一端进行插入操作,而在另一端进行删除操作的线性表。允许插入的一端称为队尾,允许删除的一端称为队头。

ADT 队列(Queue)
Data
    同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
    InitQueue(*Q):    初始化操作,建立一个空队列Q。
    DestroyQueue(*Q): 若队列Q存在,则销毁它。
    ClearQueue(*Q):   将队列Q清空。
    QueueEmpty(Q):    若队列Q为空,返回true,否则返回false。
    GetHead(Q, *e):   若队列Q存在且非空,用e返回队列Q的队头元素。
    EnQueue(*Q, e):   若队列Q存在,插入新元素e到队列Q中并成为队尾元素。
    DeQueue(*Q, *e):  删除队列Q中队头元素,并用e返回其值。
    QueueLength(Q):   返回队列Q的元素个数
endADT

4.2.循环对列

4.2.1.循环对列

首先了解下,什么是假溢出:



假设这个队列的总个数不超过5个,但目前如果接着入队的话,因数组末尾元素已经占用,再向后加,就会产生数组越界的错误,可实际上,我们的队列在下标为0和1的地方还是空闲的。我们把这种现象叫做“假溢出”。

解决假溢出的办法就是后面满了,就再从头开始,也就是头尾相接的循环。这种头尾相接的顺序存储结构就是循环队列。

此时问题出来了,空队列时,front等于rear,现在当队列满时,也是front等于rear,那么如何判断此时的队列究竟是空还是满呢?

  • 办法一是设置一个标志变量flag,当front==rear,且flag=0时为队列空,当front==rear,且flag=1时为队列满。
  • 办法二是当队列空时,条件就是front=rear,当队列满时,我们修改其条件,保留一个元素空间。也就是说,队列满时,数组中还有一个空闲单元。例如图4-12-8所示,我们就认为此队列已经满了,也就是说,我们不允许图4-12-7的右图情况出现。


问题又来了,第二种方法,由于rear可能比front大,也可能比front小,所以尽管它们只相差一个位置时就是满的情况,但也可能是相差整整一圈。
若队列的最大尺寸为QueueSize,那么队列满的条件是(rear+1)%QueueSize==front(取模“%”的目的就是为了整合rear与front大小为一个问题)。

另外,当rear>front时,此时队列的长度为rear-front。但当rear<front时,队列长度分为两段,一段是QueueSize-front,另一段是0+rear,加在一起,队列长度为rear-front+QueueSize。
因此通用的计算队列长度公式为:

(rear-front+QueueSize)%QueueSize

有了这些讲解,现在实现循环队列就不难了。

循环队列的顺序存储结构代码如下:

/* QElemType类型根据实际情况而定,这里假设为int */
typedef int QElemType;    
/* 循环队列的顺序存储结构 */
typedef struct
{
    QElemType data[MAXSIZE];
    /* 头指针 */
    int front;            
    /* 尾指针,若队列不空,
       指向队列尾元素的下一个位置 */
    int rear;             
} SqQueue;

循环队列的初始化代码如下:

/* 初始化一个空队列Q */
Status InitQueue(SqQueue *Q)
{
    Q->front = 0;
    Q->rear = 0;    
    
    return OK;
}

循环队列求队列长度代码如下:

/* 返回Q的元素个数,也就是队列的当前长度 */
int QueueLength(SqQueue Q)
{
    return (Q.rear - Q.front + MAXSIZE) % MAXSIZE;
}

循环队列的入队列操作代码如下:

/* 若队列未满,则插入元素e为Q新的队尾元素 */
Status EnQueue(SqQueue *Q, QElemType e)
{
    /* 队列满的判断 */
    if ((Q->rear + 1) % MAXSIZE == Q->front)    
        return ERROR;
    /* 将元素e赋值给队尾 */
    Q->data[Q->rear] = e;                       
    /* rear指针向后移一位置, */
    Q->rear = (Q->rear + 1) % MAXSIZE;          
    /* 若到最后则转到数组头部 */
    
    return OK;
}

循环队列的出队列操作代码如下:

/* 若队列不空,则删除Q中队头元素,用e返回其值 */
Status DeQueue(SqQueue *Q, QElemType *e)
{
    /* 队列空的判断 */
    if (Q->front == Q->rear)                
        return ERROR;
    /* 将队头元素赋值给e */
    *e = Q->data[Q->front];                 
    /* front指针向后移一位置, */
    Q->front = (Q->front + 1) % MAXSIZE;    
    /* 若到最后则转到数组头部 */
    return  OK;
}

到这里可以看出单是顺序存储,若不是循环队列,算法的时间性能是不高的,但循环队列又面临着数组可能会溢出的问题,所以我们还需要研究一下不需要担心队列长度的链式存储结构。

4.2.2.对列的链式存储结构及实现

链对列的结构:

/* QElemType类型根据实际情况而定,这里假设为int */
typedef int QElemType;       
/* 结点结构 */
typedef struct QNode         
{
    QElemType data;
    struct QNode *next;
} QNode, *QueuePtr;

/* 队列的链表结构 */
typedef struct               
{
    /* 队头、队尾指针 */
    QueuePtr front, rear;    
} LinkQueue;

入对列:

/* 插入元素e为Q的新的队尾元素 */
Status EnQueue(LinkQueue *Q, QElemType e)
{
    QueuePtr s = 
(QueuePtr)malloc(sizeof(QNode));
    /* 存储分配失败 */
    if (!s)               
        exit(OVERFLOW);
    s->data = e;
    s->next = NULL;
    /* 把拥有元素e新结点s赋值给原队尾结点的后继, */
    Q->rear->next = s;    
    /* 见上图中① */
    /* 把当前的s设置为队尾结点,rear指向s,见上图中② */
    Q->rear = s;  
            
    return OK;
}

出对列:

/* 若队列不空,删除Q的队头元素,用e返回其值,
并返回OK,否则返回ERROR */
Status DeQueue(LinkQueue *Q, QElemType *e)
{
    QueuePtr p;
    if (Q->front == Q->rear)
        return ERROR;
    /* 将欲删除的队头结点暂存给p,见上图中① */
    p = Q->front->next;          
    /* 将欲删除的队头结点的值赋值给e */
    *e = p->data;                
    /* 将原队头结点后继p->next赋值给头结点后继, */
    Q->front->next = p->next;    
    /* 见上图中② */
    /* 若队头是队尾,则删除后将rear指向头结点,见上图中③ */
    if (Q->rear == p)            
        Q->rear = Q->front;
    free(p);
    return OK;
}

对于循环队列与链队列的比较,可以从两方面来考虑,从时间上,其实它们的基本操作都是常数时间,即都为O(1)的,不过循环队列是事先申请好空间,使用期间不释放,而对于链队列,每次申请和释放结点也会存在一些时间开销,如果入队出队频繁,则两者还是有细微差异。对于空间上来说,循环队列必须有一个固定的长度,所以就有了存储元素个数和空间浪费的问题。而链队列不存在这个问题,尽管它需要一个指针域,会产生一些空间上的开销,但也可以接受。所以在空间上,链队列更加灵活。

总的来说,在可以确定队列长度最大值的情况下,可以用循环队列,如果无法预估队列的长度时,则用链队列。


5.总结

栈(stack)是限定仅在表尾进行插入和删除操作的线性表。

队列(queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。

它们均可以用线性表的顺序存储结构来实现,但都存在着顺序存储的一些弊端。因此它们各自有各自的技巧来解决这个问题。

对于栈来说,如果是两个相同数据类型的栈,则可以用数组的两端作栈底的方法来让两个栈共享数据,这就可以最大化地利用数组的空间。

对于队列来说,为了避免数组插入和删除时需要移动数据,于是就引入了循环队列,使得队头和队尾可以在数组中循环变化。解决了移动数据的时间损耗,使得本来插入和删除是O(n)的时间复杂度变成了O(1)。

推荐阅读更多精彩内容

  • 栈 栈是限定仅在表尾进行插入和删除操作的线性表。 栈又称为后进先出(Last In First Out )的线性表...
    jtsky阅读 83评论 0 0
  • 栈是限定仅在表尾进行插入和删除操作的线性表。队列是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。 栈的...
    Yix1a阅读 43评论 0 0
  • 参考:《大话数据结构》程杰 栈:限定仅在表尾进行插入和删除操作的线性表队列:只允许在一端进行插入操作,在另一端进行...
    雯雯暖暖阅读 67评论 0 1
  • 任一表达式都可看成是由操作数,运算符和界限符组成的 一个串。其中,操作数可以是常数也可以是变量或常量的标识 符,运...
    三只产品数据汪阅读 108评论 0 3
  • 栈 栈的英文单词是Stack,它代表一种特殊的线性表,这种线性表只能在固定一端(通常认为是线性表的尾端)进行插入,...
    Jack921阅读 598评论 0 5