栈与队列综合实验:表达式求值

引言

在计算机科学中,表达式求值是一个经典问题。我们常常需要将用户输入的中缀表达式(如 3 + 4 * (2 - 1))进行计算。

直接对中缀表达式进行求值比较困难,因为要考虑括号、运算符优先级等问题。而如果将其转换为后缀表达式(也叫逆波兰表达式),就可以非常方便地利用来进行计算。

在这个过程中,我们需要:

  1. 一个队列来存储转换后的后缀表达式
  2. 一个用于中缀转后缀时的操作符暂存以及后缀表达式的求值

这正是我们使用队列+栈结构的原因。


一、实验需求分析

1.1 功能要求

  1. 接收一个包含加减乘除和括号的中缀表达式字符串
  2. 将其转换为后缀表达式
  3. 利用栈完成该后缀表达式的求值
  4. 输出最终结果

1.2 技术目标

  1. 掌握中缀转后缀的算法(Shunting Yard 算法)
  2. 学会使用队列存储后缀表达式
  3. 掌握栈在表达式求值中的应用
  4. 理解数据结构如何协同工作解决实际问题

二、代码思路构建

在中缀转后缀的过程中,我们会逐个读取字符并决定它是操作数、操作符还是括号,然后根据规则输出到一个“中间结果”中。这个中间结果就是后缀表达式,它需要按照顺序保存每一个操作数和操作符,因此非常适合使用队列这种先进先出的数据结构来保存。

举个例子:

中缀表达式:3 + 4 * (2 - 1)

后缀表达式:3 4 2 1 - * +

这些字符要按顺序入队,最后依次出队求值。

在中缀转后缀时,遇到操作符或括号时,需要暂时保存它们,并根据优先级决定是否弹出到队列中。这个临时存储结构非常适合用。同样,在后缀表达式求值时,每遇到一个操作符,就需要从栈中取出两个操作数进行运算,再将结果压回栈中。这也是栈的经典应用场景。

因此,我们将整个程序划分为以下几个模块:

  1. 数据结构定义(队列和栈)
  2. 基本操作函数(初始化、判空、入队/入栈、出队/出栈等)
  3. 中缀转后缀函数
  4. 后缀表达式求值函数
  5. 主流程控制函数

接下来我们逐步展开这些部分的设计与实现细节。


三、代码实现

接下来,我们就按照实验思路,逐步进行相关函数的代码实现吧。

3.1 队列相关实现

3.1.1 队列结构体定义

对数据结构经典的定义方式一般就是直接使用结构体了,属于是一种标准化定义方式了,所以不再赘述。本次我们仍使用循环队列数据结构去实现,其中结构体中的data[] 存储字符型的后缀表达式元素(数字、运算符),frontrear` 分别表示队头和队尾指针。使用循环队列另一个原因也是为了防止“假溢出”。所以循环队列结构体定义如下:

#define MAX_QUEUE_SIZE 200          // 最大队列长度

// 队列结构体定义(循环队列[预留空位用于区分首尾])
typedef struct 
{
    int data[MAX_QUEUE_SIZE];
    int front;  // 队头指针
    int rear;   // 队尾指针
} Queue;

3.1.2 队列的初始化

紧接着,定义了某种数据结构,自然而然就要对其进行初始化。而对于循环队列来说,我们前面讲述的队列实验中也是用过,即让其队首指针和队尾指针初始为0,保证初始队列为空(首尾指针相等)即可。所以循环队列初始化代码如下:

/**
 * 初始化队列
 *
 * @param q 指向队列的指针
 */
void InitializeQueue(Queue* q)
{
    q->front = 0;
    q->rear = 0;
}

接着,初始化完毕,显然就是要进行一些循环队列相关的操作,而进行这些操作前,我们通常需要对定义的数据结构进行一些异常处理和合法判断,比如该队列是否存在、队列是否为空、队列是否已满等等问题。

3.1.3 判断队列是否为空

因此,首先我们来实现一下判断循环队列是否为空的逻辑。根据前面队列实验所述,我们很容易知道,当队列为空时,队首指针与队尾指针指向同一位置,也就是说此时循环队列的队首指针等于队尾指针则为空。所以代码实现如下:

/**
 * 判断队列是否为空
 *
 * @param q 指向队列的指针
 * @return 如果为空返回 1,否则返回 0
 */
int IsQueueEmpty(const Queue* q)
{
    return q->front == q->rear;
}

3.1.4 判断队列是否为满

然后我们来实现一下判断循环队列是否已满的逻辑。由于循环队列定义时我们会预留一个空位用于区别首尾,因此当循环队列满的时候,队尾指针的下一位与总大小模运算会与队首指针指向同一处。所以代码实现如下:

/**
 * 判断队列是否已满
 *
 * @param q 指向队列的指针
 * @return 如果已满返回 1,否则返回 0
 */
int IsQueueFull(const Queue* q)
{
    return (q->rear + 1) % MAX_QUEUE_SIZE == q->front;
}

3.1.5 入队操作

常用的异常处理实现后,接下来就可以开始编写向队列放数据的操作,即入队。便于后序用于将字符入队到后缀表达式队列中

所以首先我们判断队列是否已满,没有满就继续,否则退出。由于是循环队列以及其先进先出的原理,类比生活中人民排队,当多一个人排队时,队尾就会后移一个,也就是说,入队时队尾指针同时需要后移一位,又因为是循环队列,所以指针后移并与队列总大小进行模运算即可。因此,代码实现如下:

/**
 * 入队操作
 *
 * @param q 指向队列的指针
 * @param ch 要入队的字符
 */
void Enqueue(Queue* q, char ch)
{
    if (IsQueueFull(q))
    {
        printf("错误:队列已满。\n");
        exit(EXIT_FAILURE);
    }
    q->data[q->rear] = ch;
    q->rear = (q->rear + 1) % MAX_QUEUE_SIZE;
}

3.1.6 出队操作

有进就有出,接下来,我们来实现出队也就是从队列中移出数据的实现。便于后续从队列头部取出字符,用于后缀表达式的求值过程。

首先,既然我们需要移出元素,所以前提时队列中有元素,即队列不为空,所以要做好判断。然后就正式开始进行出队操作,同样类比生活中的排队,当排队排到自己时,就可以脱离队伍,此时就相当于出队,这个过程可以描述成:队首的人往后移一个即可,因为我们是循环队列也就是环形队列,所以不会出现后移就出现“假溢出”情况,为了保证这个队首指向一直处于循环队列大小之内,我们仍需要增加其后移后与总大小取模的运算。所以,最后代码实现如下:

/**
 * 出队操作
 *
 * @param q 指向队列的指针
 * @return 出队的字符
 */
char Dequeue(Queue* q)
{
    if (IsQueueEmpty(q))
    {
        printf("错误:队列为空。\n");
        exit(EXIT_FAILURE);
    }

    char ch = q->data[q->front];
    q->front = (q->front + 1) % MAX_QUEUE_SIZE;
    return ch;
}

3.2 栈的相关实现

3.2.1 栈的结构体定义

同理,我们以同样的原理在定义一个栈结构体,其中top 表示栈顶索引(初始为 -1),data[] 存储数值型数据(double)。便于后续处理运算符或操作符。代码如下:

#define MAX_STACK_SIZE 200      // 栈最大长度

// 栈结构体(用于操作符和计算)
typedef struct
{
    double data[MAX_STACK_SIZE];
    int top;
} Stack;

3.2.2 栈的初始化

同理,接着是对于栈结构体的初始化。每次使用栈之前都要重置状态也就是保证栈定索引为-1,确保栈为空。代码如下:

/**
 * 初始化栈
 *
 * @param s 指向栈的指针
 */
void InitializeStack(Stack* s)
{
    s->top = -1;
}

3.2.3 判断栈是否为空

与队列同理,栈的操作之前也需要做一些合法性判断,比如栈是否为空、栈是否已满等。

这里先说如何判断栈是否为空。我们知道,当栈刚初始化完时,实际上就相当于栈为空时的状态,这就意味着,如果栈为空,那么此时栈顶的索引值为-1,反之则非空。所以代码就很好实现了,如下所示:

/**
 * 判断栈是否为空
 *
 * @param s 指向栈的指针
 * @return 如果为空返回 1,否则返回 0
 */
int IsStackEmpty(const Stack* s)
{
    return s->top == -1;
}

3.2.4 判断栈是否已满

接着是判断栈是否已满。我们知道,每当一个元素进栈后,栈顶索引都是向前或向上移一位,又因为初始栈顶索引为-1,所以直到元素装满栈时,栈顶索引应该会指向MAX_STACK_SIZE - 1,换句话说,当栈顶索引值为栈总大小-1时,意味着此时栈已满,否则未满。所以代码容易实现如下所示:

/**
 * 判断栈是否已满
 *
 * @param s 指向栈的指针
 * @return 如果已满返回 1,否则返回 0
 */
int IsStackFull(const Stack* s)
{
    return s->top == MAX_STACK_SIZE - 1;
}

3.2.5 进栈操作

常用的异常判断实现后,接下来就开始正式的数据处理部分了。首先是实现将元素放进栈之中,也就是进栈的操作实现。本次实验中主要是应用于存放输入表达式中的运算符或操作符。

实际上进栈操作很简单,就是将元素的值记录后,栈顶索引自增一位,而存储数据的数组的索引此时恰好等于栈顶索引+1,因此该操作可以这样实现:先栈顶索引自增(表示有元素进栈),然后以自增后的栈顶索引作为数据数组的索引进行赋值以存储该元素即可。当然了,在开始进栈操作前,不要忘记判断栈此时是否为满。所以代码实现如下:

/**
 * 入栈操作
 *
 * @param s 指向栈的指针
 * @param value 要入栈的数值
 */
void Push(Stack* s, double value)
{
    if (IsStackFull(s))
    {
        printf("错误:栈溢出。\n");
        exit(EXIT_FAILURE);
    }
    s->data[++s->top] = value;
}

3.2.6 出栈操作

紧接着就是出栈操作,也就是将元素从栈中弹出。本次实验示例中主要用于按照优先级取出运算操作符。

由于栈的后进先出,所以出栈时不能随机存取的,我们只能将栈中的栈顶元素取出。那么如何实现呢?因为我们判断栈状态都是使用栈顶索引来判断的,这就意味着栈顶的索引与元素的状态会有很大的关系。即我们可以通过将栈顶索引值前移或者说下移,即自减1,这样即可实现逻辑上元素的出栈,同时我们返回弹出的元素。**值得注意的是,这里说的时“逻辑上的出栈”,也就是说实际上元素仍遗留在某一块内存单元,只是此时栈已经无法索引到了,而只能索引到原栈顶的前一个值。**所以,出栈的代码实现如下:

/**
 * 出栈操作
 *
 * @param s 指向栈的指针
 * @return 出栈的数值
 */
double Pop(Stack* s)
{
    if (IsStackEmpty(s))
    {
        printf("错误:栈为空。\n");
        exit(EXIT_FAILURE);
    }
    return s->data[s->top--];
}

3.2.7 查看栈顶元素

本实验是为了实现一些基本运算的表达式求值,所以当出现多个运算符时会涉及到运算优先级的问题,此时我们就需要对他们进行一个比较,也就是会需要拿出来去比较。而我们会将运算符或操作符放到栈中,所以我们需要实现一个查看栈顶元素的操作,方便后续进行比较。

查看栈顶元素也很简单,因为栈顶索引就是实际数据序数-1,也就是与数组元素索引值是一样的,所以我们直接用栈顶索引作为数据数组索引去取出相应的元素即可返回栈顶元素。当然,能查看栈顶元素的前提是:栈不为空。所以代码实现如下:

/**
 * 查看栈顶元素
 *
 * @param s 指向栈的指针
 * @return 栈顶元素
 */
double Peek(const Stack* s)
{
    if (!IsStackEmpty(s))
    {
        return s->data[s->top];
    }
    return 0.0;
}

3.3 运算逻辑实现

3.3.1 获取运算符优先级

既然已经说到和优先级相关的了,那现在就来实现获取运算符优先级的逻辑。因为本次实验中的表达式求值涉及到的是四则运算,也就是加减乘除。而我们知道,加减的优先级低于乘除的优先级,所以我们假设:如果运算符是加减,则优先级值为1;如果运算符是乘除,则优先级值为2。然后我们通过往函数传参然后进行判断,从而返回对应的优先级,依次获取我们对应运算符的优先级。代码实现如下:

/**
 * 获取运算符优先级
 *
 * @param op 运算符字符
 * @return 优先级数值
 */
int GetPrecedence(char op)
{
    if (op == '+' || op == '-') return 1;
    if (op == '*' || op == '/') return 2;
    return 0;
}

3.3.2 判断字符是否为运算符

由于我们最初输入的是一串表达式,本质上就是一连串的字符,而这串字符中包含了数字( -9~9 )、运算符( +、-、*、/ )以及操作符( “()” )。那么我们想要获取到相应类型的字符,然后存储到前面定义的不同数据结构之中的话,就需要额外做一个筛选了。因此我们就来实现一个简单的逻辑——用于判断字符是否为运算符。即先将字符传进来,然后做一个布尔运算,如果该字符与加减乘除的字符相同则返回1,否则返回0。代码实现如下:

/**
 * 判断是否是运算符
 *
 * @param ch 字符
 * @return 如果是运算符返回 1,否则返回 0
 */
int IsOperator(char ch)
{
    return ch == '+' || ch == '-' || ch == '*' || ch == '/';
}

3.3.3 四则运算操作

因为本实验最终需要实现的就是表达式求值,因此我们总会需要执行两数进行四则运算的相关操作,所以这里就来实现一个逻辑,根据给的两个数值以及运算符来进行相应的计算并返回结果。

因为我们传入的运算符是字符类型,所以我们在实现数值类型间的计算时肯定是不能直接拿传入的字符运算符形式使用的,所以我们这里将传入的运算符字符作为某一类运算的标志,根据对比传入的标志来执行相应的计算操作,也就是采用条件语句或者分支语句实现就好了。**当然,不要忘记作必要的异常处理,提高代码的健壮性。**代码实现如下:

/**
 * 执行运算
 *
 * @param a 第一个操作数
 * @param b 第二个操作数
 * @param op 运算符
 * @return 计算结果
 */
double ApplyOperation(double a, double b, char op)
{
    switch (op)
    {
    case '+': return a + b;
    case '-': return a - b;
    case '*': return a * b;
    case '/':
        if (b == 0.0)
        {
            printf("错误:除数不能为零。\n");
            exit(EXIT_FAILURE);
        }
        return a / b;
    default:
        printf("错误:未知运算符 '%c'\n", op);
        exit(EXIT_FAILURE);
    }
}

3.4 表达式变换求值

3.4.1 中-后缀表达式转换

接下来,我们需要实现一下最最最核心的逻辑——将传入的中缀表达式(如5*(6+1))转变成后缀表达式(5 6 1 + *),便于后续计算机进行计算。而我们选择的算法是基于 Dijkstra 的 Shunting Yard 算法,通过遍历表达式字符,根据类型决定是否入队或入栈

该算法实现的方式为:
遍历输入的中缀表达式,如果发现数字,则直接入队;

如果发现了运算符,则在栈为空时直接进栈,不为空时先比较栈顶运算符和准备进栈的元素的优先级,如果栈顶运算符优先级高于即将进栈元素则先将栈顶元素弹出并入队,然后再让即将进栈的元素进栈,否则如果栈顶低于即将进栈的就直接让其进栈即可;

如果发现了左括号,则直接进栈;若发现右括号,则开始判断栈是否空以及栈顶是否为左括号,如果栈非空且不为左括号则弹出栈顶元素让其入队,然后循环继续判断;反之若出现左括号,则直接弹出左括号;出现栈为空或者,直接报错栈为空(此种情况可能是缺失左括号造成)即可。

如果这些情况都不是,则意味着表达式可能异常,直接退出。

最后遍历完表达式后,再把栈中剩余的运算符放进队列中即可。

参考代码如下:

/**
 * 将中缀表达式转换为后缀表达式并存入队列
 *
 * @param expr 中缀表达式字符串
 * @param output 存储后缀表达式的队列
 */
void InfixToPostfix(const char* expr, Queue* output)
{
    Stack ops;
    InitializeStack(&ops);

    for (int i = 0; expr[i]; ++i)
    {
        char ch = expr[i];

        /* 跳过输入的空格 */
        if (ch == ' ') continue;

        /* 表达式字符判断 */
        if (isdigit(ch))    // 如果字符是数值则入队
        {
            while (isdigit(expr[i]))
            {
                Enqueue(output, expr[i++]);
            }
            Enqueue(output, ' ');  // 分隔数字
            --i;
        }
        else if (ch == '(')     // 遇见左括号直接入栈
        {
            Push(&ops, ch);
        }
        else if (ch == ')')     // 遇见右括号准备出栈操作符入队并弹出右括号
        {
            while (!IsStackEmpty(&ops) && Peek(&ops) != '(')
            {
                Enqueue(output, (char)Pop(&ops));
            }
            Pop(&ops);  // 弹出 '('
        }
        else if (IsOperator(ch))    // 字符为运算符时比较优先级再进栈
        {
            while (!IsStackEmpty(&ops) &&
                GetPrecedence(Peek(&ops)) >= GetPrecedence(ch))
            {
                Enqueue(output, (char)Pop(&ops));
            }
            Push(&ops, ch);
        }
        else
        {
            printf("错误:存在非法字符 '%c'。\n", ch);
            exit(EXIT_FAILURE);
        }
    }

    while (!IsStackEmpty(&ops))
    {
        Enqueue(output, (char)Pop(&ops));
    }
}

值得注意的是:

  1. 处理多位数的方式是连续读取所有数字字符
  2. 使用空格作为分隔符,方便后缀求值时识别数字边界:遍历字符时,发现数字时会先持续遍历字符,将连串数字找到后入队,最后会再入队一个空格,以区分个位数或多位数。
  3. 括号匹配逻辑严谨,保证正确性
  4. 最后将栈中剩余操作符全部弹出到队列

3.4.2 后缀表达式求值

最后,我们就要对得到的后缀表达式进行求值计算获得输入表达式的最终结果了。通过前面对后缀表达式转换的实现,我们可以知道后缀表达式被存在队列中,且出队过程运算符出现的顺序是按优先级从高到低出现,且运算符出现前出队的两个数值将会参与该运算符的运算,紧接着其结果会继续与队列中剩余的数值继续参与后面运算符的运算。

按照这个原理,我们可以想:既然运算的结果是随着出队过程可以逐步计算完成,那么我们可不可以直接在一个持续出队的循环中进行一些操作呢?由于我们后出队的运算符会与先出的两个值进行运算,那么我们就需要一个便于后存先取的数据结构来记录我们逐步计算的结果——很显然,得益于栈 后进先出 的特点,使用栈是最合适的。所以现在我们有一个大致的思路了:**即先定义并初始化一个栈,然后循环持续对存储后缀表达式的队列进行出队操作,同时,每出队一个数,就进栈一个;当发现出队的是运算符时,就弹出进栈的两个数并将这两个数与运算符运算的结果放进栈中,就这样以此类推,直到出队完毕。**根据这个简易的想法,我们可以写出如下的一个简单代码:

double EvaluatePostfix(Queue* postfix)
{
    /* 初始化数值计算栈 */
    Stack values;
    InitializeStack(&values);

    double num = 0;

    while (!IsQueueEmpty(postfix))  // 后缀表达式队列非空则不断出队计算
    {
        char token = Dequeue(postfix);

        if (isdigit(token))		// 是数字则进栈
        {
            num = token - '0';
            
            Push(&values, num);
            num = 0;
        }
        else if (IsOperator(token))	// 是运算符则弹出俩值运算,并结果进栈
        {
            double b = Pop(&values);
            double a = Pop(&values);
            Push(&values, ApplyOperation(a, b, token));
        }
    }

    return Pop(&values);
}

当然,这个时候还会存在一些问题,如果数值存在多位,我应该如何取适当处理避免识别成两个参与下一个运算符的运算?

因为这个意思就是说出队过程中,如果发现是数值,那这时候一方面我们确实是需要将数值放进栈中,但另一方面,我们还需要注意多位的情况。当然,我们前面实现转换后缀表达式时,其实有一个细节:就是在遍历到数字时,会有意去继续持续遍历看是否存在连续的单数字字符,然后再加空格分隔开。这个过程实际上就是为了后面我们能够区分个位数和多位数运算而特意添加的小细节。根据这个细节,这里我们就可以根据空格来合并多位的数了。即当出队时出现数字时,使用num记录;然后继续出队,如果发现出现空格则说明这个num就是个位数,所以这时候我们直接把num放进栈里面并清除num;若继续出现数字,则意味着此时的数字与上一个数字是进位关系(且上一个为高位,此时为低位),那么我们将上一个数字*10再加上此时出队的数字重新赋值给num即可,如果还有更高位也以此类推。

因此我们优化上述简单代码,如下:

double EvaluatePostfix(Queue* postfix)
{
    /* 初始化数值计算栈 */
    Stack values;
    InitializeStack(&values);

    double num = 0;

    while (!IsQueueEmpty(postfix))  // 后缀表达式队列非空则不断出队计算
    {
        char token = Dequeue(postfix);

        if (isdigit(token))		// 是数字则进栈
        {
            num = num * 10 + (token - '0');
        }
        else if(token == ' ')
        {
        	if(num != 0 || IsQueueEmpty(postfix))
        	{
                Push(&values, num);
                num = 0;
        	}
        }
        else if (IsOperator(token))	// 是运算符则弹出俩值运算,并结果进栈
        {
            double b = Pop(&values);
            double a = Pop(&values);
            Push(&values, ApplyOperation(a, b, token));
        }
    }

    return Pop(&values);
}

需要注意的是:

  1. 数字拼接逻辑要准确(考虑多位数)
  2. 使用空格作为分隔符,帮助识别数字结束
  3. 每次遇到操作符就从栈中弹出两个操作数进行计算

3.4.3 表达式输入

最后还剩输入输出部分的实现,先说输入。

由于接收的是字符串,所以实际上使用 fgetsscanf 更安全,可以避免缓冲区溢出。代码如下:

/**
 * 输入表达式
 *
 * @param expr 存储输入表达式的数组
 */
void InputExpression(char expr[])
{
    printf("请输入一个合法的表达式(支持 + - * / 和括号): ");
    if (fgets(expr, MAX_QUEUE_SIZE, stdin) == NULL)
    {
        printf("错误:读取失败。\n");
        exit(EXIT_FAILURE);
    }
    expr[strcspn(expr, "\n")] = '\0';  // 去除换行符
}

3.4.4 表达式全流程处理

最后,我们使用一个函数将表达式求值的处理逻辑全部整合到一个函数中,其中也包含了输出。使整个流程封装起来,让主函数简洁明了。代码如下:

/**
 * 表达式处理主流程
 */
void ProcessExpression()
{
    char expr[MAX_QUEUE_SIZE];
    Queue postfix_queue;

    InputExpression(expr);

    InitializeQueue(&postfix_queue);
    InfixToPostfix(expr, &postfix_queue);

    double result = EvaluatePostfix(&postfix_queue);

    printf("表达式 \"%s\" 的计算结果为:%.3f\n", expr, result);
}

3.5 主函数测试

最后,在主函数调用相关函数进行测试即可。

int main(void)
{
    printf("\n\t=== 表达式求值程序(支持 + - * / 和括号) ===\n\n");
    ProcessExpression();
    return 0;
}

四、实验总结

本次实验通过使用队列完成了中缀表达式到后缀表达式的转换以及后缀表达式的求值,不仅加深了对这两种基础数据结构的理解,也锻炼了将理论算法(Shunting Yard 算法)与实际编程相结合的能力。

在实现过程中,我们完成队列和栈的基本操作函数的基本设计,利用它们的特性完成了表达式的转换与计算。当然,个人认为上述代码仍有较大的优化空间,因此大家也不必局限于此,更多的是带着批判性思维去学习,并且尝试独立编写代码,这样才能真正提高我们的代码编写能力和逻辑思维能力!


以上便是本次文章的所有内容,欢迎各位朋友在评论区讨论,本人也是一名初学小白,愿大家共同努力,一起进步吧!

鉴于笔者能力有限,难免出现一些纰漏和不足,望大家在评论区批评指正,谢谢!

你可能感兴趣的:(数据结构与算法分析,数据结构,栈和队列,表达式求值,中-后缀表达式变换,C)