数据结构 DAY04 栈的基本概念及其应用

140 阅读8分钟

栈的基本概念

开始之前我们依然先思考几个问题:

  1. 生活中有哪些类似于栈的例子?
  2. 你认为栈在实际开发中可以解决哪些问题?

前面我们说了线性表的两种基本实现,顺序存储实现和链式存储实现。现在我们给这种线性表的基本操作增加一个限制条件,如下图所示。

只允许在表的一端进行插入和删除操作。

拥有这种特性的线性表,我们称之为==“栈(stack)”==,表的顶部我们称之为==“栈顶(top)”==,表的底部我们称之为==“栈底(bottom)”==。每次我们只对栈顶位置进行操作,因此具有先进后出,后进先出的特性,简写为FIFO。栈在我们生活中有很多例子,比如放盘子的时候,最后放的总是在最上面;再比如走迷宫,总是先试探性地往前走,走到最后没有路了,然后又回到最近走过的地方,探索其他出口。在软件方面也有很多应用,比如判断运算表达式是否合规,编译原理中的语法分析等。

在实现栈的相关操作之前,我们介绍一个和栈有关的数学性质,n 个不同的元素按照顺序入栈,其出栈元素不同排列顺序的个数可以根据以下公式计算得到。该公式我们称之为卡特兰(Catalan)数

1n+1C2nn\frac{1}{n+1} C_{2n}^{n}

具体证明可以参考组合数学教材或者维基百科。

栈的实现

  • 顺序存储结构
// 采用静态分配内存的方式
#define MAX_SIZE 100
typedef int ElemType;
typedef struct {
    ElemType data[MAX_SIZE];
    int top // 栈顶指针
} SqStack;
// 采用动态分配内存的方式,当栈的存储空间不够用的时候,重新分配一块内存
#define INIT_SIZE 100
#define INCREMENT 20
typedef struct {
    ElemType *bottom;
    ElemType *top;
    int stackSize;
} SqStack;

为了方便操作,这里我们使用静态分配的数组实现相关的操纵。

// 我们假设栈顶指针初始位置为-1,top指向栈顶元素
// 初始化
bool InitStack(SqStack *stack) {
    stack->top = -1;
    return true;
}
// 判断空栈
bool EmptyStack(SqStack *stack) {
    return stack->top == -1 ? true : false;
}
// 判断满栈
bool FullStack(SqStack *stack) {
    return stack->top == MAX_SIZE-1 ? true : false;
}
// 入栈操作
bool PushStack(SqStack *stack, ElemType data) {
    if (FullStack(stack)) {
        printf("stack full\n");
        return false;
    }
    
    stack->data[++stack->top] = data;
    return true;
}

// 出栈操作
bool PopStack(SqStack *stack, Elemtype *data) {
    if (EmptyStack(stack)) {
        printf("stack empty\n");
        return false;
    }
    
    *data = stack->data[stack->top--];
    return true;
}

动态分配内存的方式,同上一节的基本操作类似,这里不在赘述,具体可以参考严蔚敏,吴伟民老师的教材《数据结构(C语言版)》。接下来我们通过链式结构来实现栈的基本操作。

  • 链式存储结构
typedef int ElemType;
typedef struct LinkNode
{
    ElemType data;
    struct LinkNode *next;
} *LinkStack;

// 判断空栈
bool EmptyStack(LinkStack linkStack)
{
    return linkStack->next == NULL ? true : false;
}

// 判断满栈(链式结构略)

// 入栈
bool PushStack(LinkStack linkStack, ElemType data)
{
    if (linkStack == NULL)
    {
        return false;
    }
    struct LinkNode *newNode = (struct LinkNode*)malloc(sizeof(struct LinkNode));
    if (newNode == NULL)
    {
        return false;
    }
    newNode->data = data;
    newNode->next = NULL;

    newNode->next = linkStack->next;
    linkStack->next = newNode;

    return true;
}

// 出栈
bool PopStack(LinkStack linkStack, ElemType *data)
{
    if (linkStack == NULL || linkStack->next == NULL)
    {
        return false;
    }

    struct LinkNode *tmpNode = linkStack->next;
    *data = tmpNode->data;
    linkStack->next = tmpNode->next;

    return true;
}

链表操作应用

  • 链表的逆转(补充第二节链表操作的应用),如下图所示。
  • 解析

    解法一:我们可以逐个遍历链表的每个节点,然后将遍历的节点使用头插法(从头节点中插入)插入新的链表中,实现链表的逆转。

  • 示例代码

    typedef int ElemType
    typedef struct LinkNode{
        ElemType data;
        struct LinkNode *next;
    } *LinkList;
    
    bool ReverseLinkList(LinkList Sq)
    {
        // 异常处理
        if (Sq == NULL)
        {
            return false;
        }
    
        LinkList tmpSq = Sq->next;
        LinkList tmpNode = NULL;
        LinkList tmpList = (LinkList)malloc(sizeof(struct LinkNode));
        if (tmpList == NULL)
        {
            return false;
        }
        tmpList->next = NULL;
    
        while (tmpSq != NULL)
        {
            tmpNode = tmpSq;
            tmpSq = tmpSq->next;
    
            // 头插法插入新链表中
            tmpNode->next = tmpList->next;
            tmpList->next = tmpNode;
        }
    
        Sq->next = tmpList->next;
        free(tmpList);
    
        return true;
    }
    

    解法二:针对上一种解法进行优化

    bool ReverseLinkList2(LinkList Sq)
    {
        if (Sq == NULL)
        {
            return false;
        }
    
        LinkList newList = NULL;
        LinkList curNode = Sq->next;
        LinkList tmpNode = NULL;
    
        while ( curNode != NULL )
        {
            tmpNode = curNode;
            curNode = curNode->next;
            tmpNode->next = newList;
            newList = tmpNode;
        }
    
        Sq->next = newList;
        return true;
    }
    

    解法三:采用递归的思想

    递归的思想就是将一个复杂的问题分解成两部分,分别处理这两部分,如果这两部分还是很复杂,则对这两个子问题再进行分解,直到能够简单处理即可。其实递归的实现大概的思想我们都可以hold住,按照我个人的经验来说,难点在于递归终止条件的确定以及如何正确的将参数传给上一层,保证递归正确处理,链表逆转递归实现具体处理步骤如下。

  • 示例代码

    typedef int ElemType
    typedef struct LinkNode{
        ElemType data;
        struct LinkNode *next;
    } *LinkList;
    
    LinkList reverse(LinkList Sq, LinkList *tail)
    {
        LinkList reversedSq = Sq;
    
        if(Sq->next == NULL)
        {
            *tail = Sq;
        }
        else
        {
            reversedSq = reverse(Sq->next, tail);
            (*tail)->next = Sq;
            *tail = Sq;
        }
    
        return reversedSq;
    }
    
    bool ReverseLinkList3(LinkList Sq)
    {
        if (Sq == NULL)
        {
            return false;
        }
    
        LinkList *tail = (LinkList)malloc(sizeof(LinkList));
        Sq->next = reverse(Sq->next, tail);
        // 这一步不能少,递归函数中tail->next总是指向上一个节点
        (*tail)->next = NULL;
    
        return true;
    }
    

栈在表达式计算中的应用

我们先来看看一个算数表达式:

5+6/2345 + 6/2-3*4

这个算术表达式由两部分构成,运算数和运算符。根据运算数和运算符的位置不同可以将表达式分为以下几种。

前缀表达式——运算符号位于两个运算数之前;
中缀表达式——运算符号位于两个运算数之间;
后缀表达式——运算符号位于两个运算数之后。

上面的式子为中缀表达式。其对应的前缀表达式和后缀表达式分别为:

前缀表达式:+ 5  / 6 2  3 4后缀表达式:5 6 2 / + 3 4  前缀表达式:+\ 5\ -\ /\ 6\ 2\ *\ 3\ 4 \\ 后缀表达式:5\ 6\ 2\ /\ +\ 3\ 4\ *\ - \\

中缀表达式就是我们人能够正常处理和识别的表达式,人们在计算机中输入的就是中缀表达式的形式,可是这种形式并不易于计算机的处理。因此我们的计算程序要计算给定的表达式的值,需要将中缀表达式转换成前缀表达式或后缀表达式,再进行计算,下面我们来说说这几种表达式的计算及转换规则。

计算机使用前缀表达式求值:

==从右至左依次扫描表达式==,若为数字字符,则将那个字符压入堆栈,若为运算符,则从堆栈中弹出两个运算数,按照==栈顶元素 OP 次栈顶元素==的顺序计算结果并压入栈中,继续向左扫描表达式字符串,重复前面描述的处理逻辑,最后计算得出的结果即为表达式的值。

例:计算前缀表达式:+ 5 - / 6 2 * 3 4

  1. 从右至左扫描,将4、3压入栈中;
  2. 遇到 * 运算符,弹出3和4(3为栈顶元素,4为次顶元素),计算出3*4的值12压入栈中;
  3. 将2、6压入栈中;
  4. 遇到 / 运算符,弹出6和2(6为栈顶元素,2为次顶元素),计算出6/2的值3压入栈中;
  5. 遇到 - 运算符,弹出3和12(3为栈顶元素,12为次顶元素),计算出3-12的值-9压入栈中;
  6. 将5压入栈中
  7. 遇到 + 运算符,弹出5和-9(5为栈顶元素,-9为次顶元素),计算出5-9的值-4压入栈中;
  8. 扫描结束,取出栈中的值即为最终结果。

计算机使用后缀表达式求值: ==从左至右扫描表达式==,若为数字字符,则将该字符压入堆栈,若为运算符,则从堆栈中弹出两个运算数,按照==次栈顶元素 OP 栈顶元素==的顺序计算结果并压入栈中;继续向右扫描表达式的值,重复前述的处理逻辑,最后计算得出的值即为表达式的值。

例:计算后缀表达式:5 6 2 / + 3 4 * -

  1. 从左至右扫描,将5、6、2压入堆栈;
  2. 遇到 / 运算符,因此弹出2和6(2为栈顶元素,6为次顶元素,注意与前缀表达式做比较),计算出6/2的值3,压入栈中;
  3. 遇到 + 运算符,弹出3和5(3为栈顶元素,5为次顶元素),计算5+3的值8,压入栈中;
  4. 将3、4压入堆栈
  5. 遇到 * 运算符,弹出4和3(4为栈顶元素,3为次顶元素),计算出3*4的值12压入栈中;
  6. 遇到 - 运算符,弹出12和8(12为栈顶元素,8为次顶元素),计算出8-12的值-4压入栈中;
  7. 扫描结束,取出栈中的值即为最终结果。

中缀表达式、后缀表达式和前缀表达式相互转换

下期见。。。

参考文献

[1] 数据结构:C语言版 . 严蔚敏等 . 北京:清华大学出版社,2007

[2] 数据结构考研复习指导:王道论坛 . 电子工业出版社

[3] 数据结构:第二版 . 陈越 . 北京:高等教育出版社

[4]前缀、中缀、后缀表达式:Antineutrino

获取更多知识请关注公众号——无涯的计算机笔记