☰
栈的两个经典应用:括号匹配与表达式求值
2026/10/14 18:33:48 网站建设 项目流程

栈这名字听起来挺简单——一摞盘子,后放先取。但真把它用在问题里,最经典的莫过于两个场景:括号匹配和表达式求值。这两个问题几乎出现在每一本数据结构的教材里,也出现在每一场笔试和面试中。这篇我打算把这两块掰开揉碎,从思路到代码,到数据结构的细节选择,到调试时踩过的坑,一次性讲清楚。适合正在学数据结构的学生,也适合想补一补算法基础的开发者。栈不是只有“先进后出”这一个概念,它是一整套思考问题的方式。

1. 整体设计思路:为什么匹配类问题天生适合用栈

1.1 栈的核心特征:解决“最近相关性”问题

栈的本质是一种受限的线性表,只允许在一端插入和删除。这个限制看起来是缺点,却恰好造就了它的强项:处理数据中具有“最近相关性”的结构。

什么叫最近相关性?举个例子。你在看一段HTML代码,看到一个<div>,接着又有嵌套的<span>,最后是</span>和</div>。哪个闭合标签应该先和哪个开始标签配对?显然是最近的那个。这和你把文件放进文件夹再取出是一个道理,后放进去的先处理。

括号匹配就是最近相关性的典型场景。一个左括号出现后,不一定立刻匹配——它可能在等后面中间夹着的一堆括号先配对完,最后才轮到它。这种“先出现的最后配对”关系,恰好就是栈的后进先出特性。

所以当你分析一个问题时,只要发现其中隐藏着“后到的先处理、先到的后收尾”的规律,基本上栈就是合适的结构。这种识别能力比记住几个API更重要。

1.2 顺序栈与链栈的选择

栈的实现方式主要有两种:顺序栈(数组实现)和链栈(链表实现)。很多初学者觉得这东西随便选一个就行,其实差别不小。

顺序栈在内存中连续存储,通过一个栈顶指针标记当前位置。它的优点是访问快、空间利用率高,缺点是栈的大小在创建时就要确定,如果预估不准,可能出现溢出。不过实际开发中栈的深度通常有明确上限,所以这点可以接受。

链栈用链表节点存储数据,每个节点包含数据和指向下一个节点的指针。它的优点是不用担心容量,缺点是每个节点额外占用指针空间,并且节点分散在内存各个位置,缓存不友好。

我在实际学习中更推荐先用顺序栈,原因很简单:代码简洁、便于调试、性能直观。教材里的大部分题目也是基于顺序栈讲解的。等你把顺序栈的原理吃透了,再去看链栈的实现就是顺手的事。下面给出两种结构的定义,方便对比:

// 顺序栈定义 #define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; // 这里以int为例 int top; // 栈顶下标,空栈为-1 } SeqStack; // 链栈节点定义 typedef struct Node { int data; struct Node* next; } LinkStackNode; typedef struct { LinkStackNode* top; // 栈顶指针 int count; // 节点数量 } LinkStack;

1.3 栈的基本操作的实现细节

不管哪种实现,栈的基础操作都逃不过这五个:初始化、判断是否为空、判断是否已满、压栈、出栈。还有两个常用变体:取栈顶元素(不弹出)和清空栈。

我先写一套顺序栈的核心操作。这套代码我建议你抄下来反复读,因为它虽然简单,但包含了大量后续代码的基础逻辑。

#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define MAX_SIZE 100 typedef struct { char data[MAX_SIZE]; int top; } CharStack; // 初始化:栈顶指针置为-1 void initStack(CharStack* s) { s->top = -1; } // 判空:栈顶为-1即为空 bool isEmpty(CharStack* s) { return s->top == -1; } // 判满:栈顶到达最大下标 bool isFull(CharStack* s) { return s->top == MAX_SIZE - 1; } // 压栈:先移动指针,再写入数据 bool push(CharStack* s, char c) { if (isFull(s)) { printf("栈满,无法入栈\n"); return false; } s->top += 1; s->data[s->top] = c; return true; } // 出栈:先取出数据,再移动指针 bool pop(CharStack* s, char* c) { if (isEmpty(s)) { printf("栈空,无法出栈\n"); return false; } *c = s->data[s->top]; s->top -= 1; return true; } // 取栈顶元素:只读不弹 bool peek(CharStack* s, char* c) { if (isEmpty(s)) { return false; } *c = s->data[s->top]; return true; }

这里有一个细节值得注意:push和pop的参数设计。我在初学的时候习惯写void push(CharStack* s, char c),不返回任何值。后来在实战中发现,如果栈满了或者栈空了,静默失败会留下隐患。更好的做法是让push和pop返回bool值,调用方可以根据返回值判断操作是否成功。这种设计思路在高阶的数据结构实现中会反复用到。

还有一个细节是top指针的处理。很多人容易搞混:有的写法是top初始化为0,数据从data[1]开始放;有的写法是top初始化为-1,数据从data[0]开始放。这两种都是对的,但我更推荐-1这种写法,因为它在逻辑上更贴近“空栈”的概念,且在使用数组下标时不容易出现越界。

2. 括号匹配问题的完整实现

2.1 问题描述与匹配算法

括号匹配的问题是:给定一个字符串,里面包含( )、[ ]、{ }三种括号,判断括号的使用是否正确。正确的定义有两个层面:一是左右括号数量一样多,二是括号的嵌套顺序正确。

数量相等是必要不充分条件。字符串([)]中,四种括号数量都对,但顺序是错的——(应该等]后面的)吗?不是。正确的匹配应该是([ ])这样嵌套的,而不是交叉的。

用栈解决这个问题的算法很经典,只有三步:

  1. 遍历字符串,遇到左括号就压入栈。
  2. 遇到右括号,先检查栈是否为空。如果为空,说明当前右括号没有对应的左括号,匹配失败。如果不为空,弹出栈顶元素,检查是否和当前的右括号配对。
  3. 遍历结束后,如果栈为空,说明所有左括号都得到了匹配;否则说明还有未闭合的左括号。

为什么遇到右括号只需要检查栈顶?因为栈顶是最近一个未匹配的左括号。如果它都不能配对,那更早的左括号就更不可能和当前右括号配对了。

配对检查的逻辑可以用一个辅助函数处理:

bool isMatch(char left, char right) { if (left == '(' && right == ')') return true; if (left == '[' && right == ']') return true; if (left == '{' && right == '}') return true; return false; }

2.2 完整代码与逐步演算

下面给出一个单函数的版本,把所有逻辑都在一个函数里做完。这样方便初学者一次看清流程:

#include <stdio.h> #include <stdbool.h> #include <string.h> #define MAX_SIZE 100 // 省略上一节中的栈操作函数 bool isMatch(char left, char right) { if (left == '(' && right == ')') return true; if (left == '[' && right == ']') return true; if (left == '{' && right == '}') return true; return false; } bool checkBrackets(const char* str) { CharStack stack; initStack(&stack); int len = strlen(str); for (int i = 0; i < len; i++) { char c = str[i]; if (c == '(' || c == '[' || c == '{') { if (!push(&stack, c)) { return false; // 栈满了直接判定失败 } } else if (c == ')' || c == ']' || c == '}') { if (isEmpty(&stack)) { printf("右括号多余: %c at index %d\n", c, i); return false; } char top; pop(&stack, &top); if (!isMatch(top, c)) { printf("括号不匹配: %c 与 %c\n", top, c); return false; } } // 其他字符直接忽略 } if (!isEmpty(&stack)) { char top; peek(&stack, &top); printf("左括号多余: %c\n", top); return false; } return true; } int main() { const char* test1 = "{[()]}"; const char* test2 = "([)]"; const char* test3 = "((()"; printf("%s -> %s\n", test1, checkBrackets(test1) ? "匹配" : "不匹配"); printf("%s -> %s\n", test2, checkBrackets(test2) ? "匹配" : "不匹配"); printf("%s -> %s\n", test3, checkBrackets(test3) ? "匹配" : "不匹配"); return 0; }

我们手动走上一个例子,{[()]}:

  • 第1个字符{,左括号,入栈。栈:{
  • 第2个字符[,左括号,入栈。栈:{ [
  • 第3个字符(,左括号,入栈。栈:{ [ (
  • 第4个字符),右括号,弹出栈顶(,匹配,栈:{ [
  • 第5个字符],右括号,弹出栈顶[,匹配,栈:{
  • 第6个字符},右括号,弹出栈顶{,匹配,栈为空。

全部通过,匹配成功。

再看([)]:

  • 第1个字符(,入栈。栈:(
  • 第2个字符[,入栈。栈:( [
  • 第3个字符),弹出栈顶[,和)比对,不匹配,立即返回false。

问题就出在这里——[还没闭合,)却提前出现了,这属于交叉嵌套。用栈来识别这个问题非常高效,一个栈顶元素的对比就发现了。

2.3 复杂度的分析

这个算法的时间复杂度是O(n),空间复杂度是O(n)。n是字符串长度。

时间上,每个字符最多被处理一次:左括号入栈一次,右括号出栈一次,其余的比对都是O(1)操作,因此总操作次数和字符串长度成正比。空间上,最极端的情况是字符串全是左括号,栈中需要存储n个元素,因此空间消耗也是线性的。

这两个复杂度对这个算法来说几乎是最优的——你不可能少于O(n)的时间,因为每个字符至少要读一遍才能判断;你也不可能少于O(n)的最坏空间,因为如果右括号从不出现,你不保存所有左括号就无法匹配。所以栈结构在这个问题上的时间空间效率都是天花板。

2.4 边界情况的讨论

判断一个算法是否严谨,不是看它在正常输入下多顺利,而是看它在边界情况下会不会出bug。

我经常在初学者的代码里发现这么几类遗漏:

  • 空串。空串应该返回true,因为没有什么需要匹配的。上面的代码在len为0时直接跳过循环,栈为空,返回true,正确。
  • 字符串中只有左括号,比如(((。遍历结束后栈不为空,判定失败,正确。而且还能告诉你具体是哪个左括号没匹配上。
  • 字符串中只有右括号,比如)))。遍历时遇到第一个右括号时栈为空,直接返回false,正确显示右括号多余。
  • 字符串中包含无关字符,比如字母、数字、符号。处理方式取决于需求。如果你是在校验一段代码,那么可以把非括号字符跳过;如果是要严格校验,也可以直接返回false。上面的代码默认跳过。

有一种情况需要特别小心:字符串非常长,比如几百万个括号。这时如果栈的容量不够,就会溢出。我上面的代码用了固定大小的数组,在栈满时返回false。这种方式能防止越界,但逻辑上等于把所有长输入的括号匹配都判定为失败,严格来说不算正确匹配。如果你要处理长字符串,建议改用动态扩容的顺序栈,数据结构课程中通常称为动态顺序表,也就是数组不够用时就重新分配一块更大的内存。实际工程中还可以直接把栈的类型换成链式结构,没有容量上限。

3. 表达式求值问题的完整实现

3.1 中缀表达式与后缀表达式的关系

表达式求值比括号匹配高了一个台阶。先说清楚这里的术语。

我们平时写的3 + 4 * 5是中缀表达式,运算符写在两个操作数的中间。这种写法对人很友好,但对计算机不友好,因为必须考虑运算符优先级和括号。为了处理优先级,你得维护很多规则。

后世的解决方案是借助后缀表达式,也叫逆波兰表达式。后缀表达式的特点是运算符写在操作数后面,比如中缀的3 + 4写成后缀是3 4 +。更华丽一点的例子里,名题(3 + 4) * 5 - 6对应的后缀表达式是3 4 + 5 * 6 -。

后缀表达式的一大好处是:它不需要括号,也不需要优先级规则,只要按顺序扫描,见到操作数就压栈,见到运算符就弹出两个操作数计算,再把结果压回去,一遍就做完。整个求值过程完全线性。

因此表达式求值问题被拆成两个阶段:

  • 阶段一:把中缀表达式转换为后缀表达式。
  • 阶段二:计算后缀表达式的值。

两个阶段都依赖栈。这也是为什么这个题目能成为数据结构的经典应用题——它把一个复杂问题分解为两个栈的巧妙使用。

3.2 中缀转后缀的规则与实现

中缀转后缀的算法一般称为调度场算法。核心思想是运算符不是一读到就输出,而是要先和栈顶的运算符比较优先级。优先级高或相等的,先输出;优先级低的,先压栈等待。括号则完全改变这一过程。

具体规则如下:

  1. 读到数字:直接输出到后缀表达式。
  2. 读到左括号:无条件压入运算符栈。
  3. 读到右括号:把运算符栈顶的运算符依次弹出并输出,直到碰到左括号为止,然后把左括号弹出丢弃。
  4. 读到普通运算符(如+ - * /):只要运算符栈不空,且栈顶不是左括号,且栈顶运算符的优先级大于等于当前运算符的优先级,就弹出栈顶并输出;然后当前运算符入栈。

这里有一个初学者容易混淆的细节:什么时候用“大于”,什么时候用“大于等于”?

我的建议是采用“大于等于”弹出。原因是一个表达式如果是左结合的,比如a - b - c,实际含义是(a - b) - c。处理到第二个减号时,栈顶的减号和当前减号优先级相同,先弹出栈顶减号,再压入当前减号,这样在计算时能保证从左到右的顺序。如果用单纯“大于”,那么a - b - c可能会被转换为a b c - -,计算顺序就变了,结果会出问题。

如果设计的语言是右结合性的,则要用“大于”。不过C语言中的大多数运算符都是左结合,所以用“大于等于”更符合通常的实现。

下面给出转换的核心代码:

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #define MAX_EXPR 100 int precedence(char op) { if (op == '+' || op == '-') return 1; if (op == '*' || op == '/') return 2; return 0; // 括号 } // 这个函数把后缀表达式放到out数组中 void infixToPostfix(const char* infix, char* out) { CharStack stack; initStack(&stack); int len = strlen(infix); int outIdx = 0; for (int i = 0; i < len; i++) { char c = infix[i]; if (isspace(c)) { continue; } // 如果是数字或小数点,读取整个数字并输出 if (isdigit(c) || c == '.') { while (isdigit(infix[i]) || infix[i] == '.') { out[outIdx++] = infix[i++]; } out[outIdx++] = ' '; // 用空格分隔操作数 i--; // 因为for循环还会自增 } else if (c == '(') { push(&stack, c); } else if (c == ')') { char op; while (pop(&stack, &op) && op != '(') { out[outIdx++] = op; out[outIdx++] = ' '; } // 理论上循环结束时op是'(',已经被丢弃 } else if (c == '+' || c == '-' || c == '*' || c == '/') { char top; while (!isEmpty(&stack) && peek(&stack, &top) && top != '(' && precedence(top) >= precedence(c)) { pop(&stack, &top); out[outIdx++] = top; out[outIdx++] = ' '; } push(&stack, c); } } // 表达式扫描结束,把栈里剩余的运算符全部输出 char top; while (pop(&stack, &top)) { if (top != '(') { out[outIdx++] = top; out[outIdx++] = ' '; } } out[outIdx] = '\0'; }

这个版本的代码我写得很刻意——输出操作数时在末尾加了空格。为什么要加空格?因为如果操作数是多位数或者带小数点,不加分隔符会导致后缀表达式无法区分12 3和123。空格在这里就是操作数之间的分隔符,是我为后续求值环节故意留的接口。

注意一个细节:内层循环读取数字时用了while (isdigit(infix[i]) || infix[i] == '.'),然后在出循环后i--。为什么需要这步?因为在循环内部已经把i推进到了字符后,但外面还有一个for循环的i++,如果不做这步补偿,会跳过表达式里的一个字符。这种细小的下标问题在实际写代码时经常出现,建议多打日志观察。

转换过程在中缀表达式3 + 4 * 5上的行为是这样的:

  • 读到3,输出。后缀:3
  • 读到+,栈空,压栈。栈:+
  • 读到4,输出。后缀:3 4
  • 读到*,栈顶是+,优先级1小于2,不弹出,直接压栈。栈:+ *
  • 读到5,输出。后缀:3 4 5
  • 表达式结束,弹出所有运算符。输出*和+。后缀:3 4 5 * +

完全没有问题。再看带上括号的(3 + 4) * 5 - 6:

  • 读到(,压栈。
  • 读到3,输出。
  • 读到+,栈顶是(,不弹出,压栈。
  • 读到4,输出。
  • 读到),弹出直到(,输出+。
  • 读到*,此时栈空,压栈。
  • 读到5,输出。
  • 读到-,栈顶*优先级2大于-的1,弹出*,然后-压栈。
  • 读到6,输出。
  • 表达式结束,弹出-。

最终后缀表达式:3 4 + 5 * 6 -,正是前面提到的标准形式。

3.3 后缀表达式求值的逐位演算

有了后缀表达式,计算就简单多了,不需要再回头看优先级。操作流程是:

  1. 从左到右扫描后缀表达式。
  2. 操作数压入操作数栈。
  3. 遇到运算符,弹出栈顶两个操作数,先弹出的数作为右操作数,后弹出的数作为左操作数,计算后将结果压回栈。
  4. 扫描结束后,栈中剩下的唯一元素就是表达式的结果。

这里面最容易踩的坑是弹出顺序。假设栈里从栈底到栈顶是a, b,先弹出b,再弹出a。如果运算符是减法或除法,错误的顺序会导致结果错误。我见过不少同学在这一步把a - b写成了b - a,整个结果就不对了。

原因很简单:后缀表达式中操作数的出现顺序是“左操作数在前,右操作数在后”。栈的特性是先弹出的反而是后进入的,所以必须先弹右操作数,再弹左操作数。这在代码中通常写成:

double right = popValue(&stack); double left = popValue(&stack); switch (op) { case '+': pushValue(&stack, left + right); break; case '-': pushValue(&stack, left - right); break; case '*': pushValue(&stack, left * right); break; case '/': pushValue(&stack, left / right); break; }

如果写反了,3 4 -会算成1而不是-1。这个问题在考场上很常见,我特别喜欢在代码审查时提醒别人。

下面是一段可以直接运行的后缀求值代码:

typedef struct { double data[MAX_EXPR]; int top; } ValueStack; void initValueStack(ValueStack* s) { s->top = -1; } bool pushValue(ValueStack* s, double val) { if (s->top >= MAX_EXPR - 1) return false; s->data[++s->top] = val; return true; } bool popValue(ValueStack* s, double* val) { if (s->top < 0) return false; *val = s->data[s->top--]; return true; } double evaluatePostfix(const char* postfix) { ValueStack stack; initValueStack(&stack); int len = strlen(postfix); for (int i = 0; i < len; i++) { char c = postfix[i]; if (isspace(c)) { continue; } if (isdigit(c) || c == '.') { char buffer[32]; int idx = 0; while (isdigit(postfix[i]) || postfix[i] == '.') { buffer[idx++] = postfix[i++]; } buffer[idx] = '\0'; pushValue(&stack, atof(buffer)); i--; } else if (c == '+' || c == '-' || c == '*' || c == '/') { double right, left; popValue(&stack, &right); popValue(&stack, &left); double result; switch (c) { case '+': result = left + right; break; case '-': result = left - right; break; case '*': result = left * right; break; case '/': result = left / right; break; } pushValue(&stack, result); } } double final; popValue(&stack, &final); return final; }

来看3 4 + 5 * 6 -的演算过程:

  • 读3,入栈。栈:3
  • 读4,入栈。栈:3 4
  • 读+,弹出4和3,计算3+4=7,入栈。栈:7
  • 读5,入栈。栈:7 5
  • 读*,弹出5和7,计算7*5=35,入栈。栈:35
  • 读6,入栈。栈:35 6
  • 读-,弹出6和35,计算35-6=29,入栈。栈:29

最终结果是29。手动算一下原中缀表达式(3 + 4) * 5 - 6,结果正是29,算法成立。

3.4 完整工程代码:把两个阶段拼起来

这里把中缀转后缀和后缀求值封装到一个演示程序里,方便直接复现。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #define MAX_EXPR 200 // 这里放前面所有提到的栈操作函数、precedence函数、infixToPostfix函数、evaluatePostfix函数 // 由于篇幅原因,省略重复声明,实际运行时请按顺序粘贴 int main() { char infix[MAX_EXPR]; char postfix[MAX_EXPR]; printf("请输入中缀表达式(支持+ - * / 和括号): "); fgets(infix, MAX_EXPR, stdin); // 去掉fgets带来的换行符 infix[strcspn(infix, "\n")] = '\0'; infixToPostfix(infix, postfix); printf("后缀表达式: %s\n", postfix); double result = evaluatePostfix(postfix); printf("计算结果: %f\n", result); return 0; }

在我的实际测试中,输入3 + 4 * ( 2 - 1 )时,输出的后缀是3 4 2 1 - * +,计算结果为7,和预期一致。输入( 15 / 3 + 2 ) * 4时,后缀是15 3 / 2 + 4 *,计算结果为28,也一致。

需要提醒的是,这个版本的代码没有做输入校验。如果用户输入包含非法字符比如字母或连续运算符,程序可能崩溃或给出错误结果。一个更严谨的工程版本应该在转换阶段就检查语法,但这会显著增加代码量。作为数据结构实验,这个简化版本足以展示栈的核心应用,但如果你要交付一个真正能用的计算器,还需要补充语法检查与错误恢复机制。

3.5 两种求值方案的真实对比

除了转后缀再求值,表达式求值还有一种思路:使用两个栈同时进行,一个栈存操作数,一个栈存运算符,在扫描过程中根据优先级决定是立即计算还是继续压栈。这种双栈法的好处是不需要额外生成后缀表达式字符串,省了一点空间,并且转换与求值同步完成,在处理交互式输入时更自然。

但从可读性角度看,我更喜欢先转后缀再求值。因为两步走每一步只用解决一个相对独立的问题,代码结构清晰,出了故障也容易定位。双栈法把所有逻辑揉在每个字符的处理分支里,代码往往更难看懂。

实际项目中,如果你要做一个计算器,我建议用一个现成的表达式解析库,比如在成熟语言里可以直接用自带的eval或表达式求值库,不必自己从零写调度场。但学习阶段我强烈建议手写一遍,因为这里面包含的栈、优先级处理、边界判断等技巧会出现在大量更高阶算法中,比如编译原理中的语法分析器就和这个思路一脉相承。

4. 调试经验与常见问题排查技巧

4.1 栈操作中的空指针与越界问题

在我初学的时候,最痛苦的不是理解算法,而是写出来的代码时不时崩溃。仔细分析后,绝大多数崩溃集中在这几个位置:

一是出栈时没有检查栈是否为空。如果对空栈调用pop,数组下标会变成-2、-3,轻则乱读内存,重则段错误。我常用的排查方式是在pop和peek里加打印,每次操作后输出栈顶下标和当前栈内容。虽然效率不高,但用在调试阶段非常有效。

二是数组容量设置过小。如果你把MAX_SIZE设为50,而表达式是100个字符,那么压栈时就会误判为栈满。我建议在实验阶段直接把MAX_SIZE设得大一些,比如200或1000,免得容量问题干扰算法调试。等算法逻辑全部验证通过后,再回头优化容量也不迟。

三是忘记初始化栈。很多人定义了CharStack stack但没有调用initStack,导致stack.top是一个随机值。第一次push的时候,程序可能往data数组的随机位置写数据,最后读到完全不可预期的结果。这类bug非常隐蔽,因为运行时不一定立刻崩溃,而是表现为输出偶尔正确、偶尔错误。排查方法是在main入口打个断点,看看栈初始化是否执行了。

4.2 运算符优先级与括号处理的经典错误

表达式求值中最容易出错的细节有三个。

第一个是优先级比较的方向写反了。如果用top < c的优先级作为弹出条件,会导致低优先级的运算符被先弹出,最终后缀表达式顺序错误。比如输入1 + 2 * 3,如果优先级判断出错,转换出的后缀可能是1 2 3 * +或1 2 * 3 +,前者是正确的一倍,后者是错误。排查时直接打印转换后的后缀表达式,再人工验证一遍是否符合预期。

第二个是右括号处理时没有弹出左括号。有些人的循环条件是while (!isEmpty && top != '('),但循环结束后没有主动把左括号pop掉,导致左括号残留在栈里,后续的处理全乱。正确的做法是在while循环弹出元素后,额外的pop操作把栈顶的左括号丢掉,或者在while循环的条件里加一个约定——凡是右括号触发时,循环里“弹出到左括号为止,且包含左括号的弹出”。我的建议是单独一行代码明确pop掉左括号,这样代码可读性最好。

第三个是负数和一元运算符的问题。比如表达式-3 + 5中的-并不是减法运算符,而是负号。上面的算法会对这个-按普通运算符处理,导致出栈时操作数不足。如果要支持负数和一元运算符,通常的做法是判断-是否出现在表达式开头或其前一个字符是运算符或左括号,如果是,就将-与紧跟的数字合并为一个负数操作数来处理。这个处理方式我在换行表达式求值工具时经常用到。

4.3 性能问题与优化思路

虽然教材题目一般不苛求性能,但在真实应用中需要考虑。假如你要对一个百万字符的超长表达式进行求值,栈的使用会相当频繁。可以考虑几个方向优化:

第一个方向是避免使用函数调用的开销。每次push和pop都调用一次函数,在大量循环中会带来不小的时间损耗。如果你非常追求性能,可以把栈操作用宏定义或内联函数实现,或者在循环体内直接操作栈结构体。

第二个方向是预分配空间。如果知道表达式的最大长度,可以在初始化时直接把操作数栈和运算符栈的容量都配好,避免动态扩容带来的额外复制开销。

第三个方向是减少后缀表达式的内存占用。在后缀求值阶段,一个常见的优化是把“输出后缀表达式到字符串”和“直接在后缀表达式生成过程中同步求值”合并。但这样做代码会变得更难读,所以我的建议是在学习阶段不做此类优化,等理解了原理后再说。

第四个方向是使用更合适的数据类型。如果表达式中的数字是整数,可以使用整型栈而不是浮点型栈。浮点运算比整型慢,而且在精度敏感场景下可能引入误差。对大多数数据结构实验而言,整型操作数和浮点操作数差别不大,但长期习惯用最恰当的类型,会让代码更专业。

4.4 测试用例的设计原则

很多同学写完代码后只会用一两个简单表达式测试,这是远远不够的。我建议至少准备下面这一组测试用例,按顺序跑一遍:

  • 空表达式,应当报错或返回0。
  • 单个数字,如5,应当返回5。
  • 单运算符,如1+2,应当返回3。
  • 基本优先级,如1+2*3,应当返回7。
  • 括号改变优先级,如(1+2)*3,应当返回9。
  • 连续相同优先级,如10-3-2,应当返回5,而不是9。
  • 除法与浮点数,如9/2,应当返回4.5。
  • 复杂嵌套,如((1+2)*(3+4))/(5-1),应当返回2.25(这题的结果是21/4=5.25,我算错了,改成((1+2)*(3+4))/(5-1)=21/4=5.25——注意这里表达式是14/4=3.5?不对,让我重新口算:(1+2)*(3+4)=3*7=21,5-1=4,21/4=5.25。对,结果是5.25)。

括号匹配的测试用例则应该包括:

  • 空串,匹配。
  • (),匹配。
  • [],匹配。
  • {},匹配。
  • {[()]},匹配。
  • ([)],不匹配。
  • ((()),不匹配。
  • ()),不匹配。
  • ((())),匹配。

这些用例覆盖了正常、交叉、多余左括号、多余右括号、嵌套等全部情况。任何一套通过全部用例的代码,基本可以认为是健壮的。

4.5 如何在实验报告中清晰表达

如果你是在做课程实验,报告里除了代码,还应该写清楚四部分:

第一部分是需求分析。明确输入是什么、输出是什么、有哪些约束条件。比如“输入一个可能包含空格和括号的中缀表达式,输出计算结果,支持+ - * / 和括号优先”。

第二部分是设计思路。用文字描述数据结构和核心算法的思想,不需要写太多代码。让老师看出你理解栈的先进后出如何匹配括号的嵌套关系,以及运算符栈如何保存优先级。

第三部分是实现细节。把你认为最难的几个点单独拿出来说明,比如左括号在运算符栈中的处理、后缀表达式中操作数的分隔方法、栈顶指针的初始化和判断条件。

第四部分是测试分析。把上面的测试用例整理成表格,记录每条用例的输入输出和实际运行结果,最好再包括一个演算过程。

写实验报告时,最大的坑是只贴代码不写说明。很多学生以为代码能跑就万事大吉,但实际上,数据结构这门课考察的核心就是“你会不会用数据结构解决问题”,而不是“你会不会背代码”。把思路说清楚,比贴几百行代码更有价值。

5. 从题目到工程应用的扩展路线

5.1 括号匹配在真实系统中的位置

括号匹配这个看似简单的题目,在真实系统中无处不在。代码编辑器里的语法高亮、括号补全和自动缩进,都要靠括号匹配来定位配对的括号。如果你曾经用过一个编辑器,按下热键就能把光标从当前位置跳到配对的括号上,那就是括号匹配算法在发挥作用。

JSON和XML这类数据格式的校验也依赖栈思路。JSON中{}和[]的嵌套,本质和括号匹配一模一样;XML标签的配对要求严格嵌套,也是同一个模型。只不过真实系统里栈元素不再是单个字符,而是一个个结构体。思考方式不变,变的只是栈里存的“东西”。

5.2 表达式求值的工业级扩展

表达式求值应用的范围远超教科书里的简单四则运算。数据库查询优化器在处理SQL条件时,需要把中缀形式的查询条件转换为更适合执行的形式,本质上是一棵表达式树的构建与求值。电子表格软件中的公式引擎,本质上就是一个表达式求值系统,只不过运算符的种类更多,还包含单元格引用和函数调用。

如果你把中缀转后缀的运算符扩展为自定义函数、比较运算符、逻辑运算符,并增加函数调用的栈帧处理,那你大致已经写出了一个小型脚本解释器的运算部分。编译原理课程中讲到的递归下降分析、抽象语法树构建,都和这里所说的栈应用一脉相承。表达式求值问题从来不是孤立的知识点,它像一把钥匙,打开的是整个语法分析的大门。

5.3 进一步挑战:自建表达式树

如果想在基础题目之上进阶一步,我建议你自己动手体会一个有趣的任务:不要只计算后缀表达式的值,而是把后缀表达式构建成表达式树。操作数作为叶子节点,运算符作为内部节点,然后通过树的遍历输出中缀表达式或前缀表达式。

这个任务有几个好处:

  • 它让你理解表达式树的建立过程,和栈的顺序打印是互逆操作。
  • 它可以让你发现中缀转后缀时丢失的括号信息如何恢复,以及优先级如何体现在树结构上。
  • 它为后续学习语法分析中的抽象语法树打基础。

我在做这个任务时,最大的体会是:栈和树不是两个孤立的结构,它们经常在同一份逻辑里协同工作。这也是数据结构课程后期一个重要主题——各种结构不是彼此绝缘的,而是互相嵌套、互相转化的。

6. 写在最后的实操心得

这次把栈的两个经典应用完整梳理了一遍,我自己在复习的过程里也有一些新的感悟。

第一点,栈的核心价值不在于它存储了什么,而在于它施加了什么样的存取约束。正是因为只能从一端进出,它才能精确地模拟“最近相关”这种语义。如果你想存储一批数据,用数组就行;但如果你要处理的数据天然具有嵌套结构,栈就是不二之选。

第二点,结构化的分解永远好过一锅端的聪明代码。把表达式求值拆成中缀转后缀、后缀求值两个阶段,每一步都简单清晰。但如果你一上来就想一步到位,用一个复杂的循环结构完成全部工作,不仅容易写出bug,还很难调试。先把它写对,再考虑是不是可以合并优化,这是我在多个项目里反复验证过的原则。

第三点,调试栈相关代码时最有力的工具就是打印。在入栈、出栈的每个关键节点打印当前栈的内容和后缀表达式的当前产出,几乎能立刻定位逻辑错误。等一切正常后再把调试输出关掉,换成简洁的日志接口。

第四点,如果你觉得这个题目做完了就结束了,那就太可惜了。试着改造上面的代码,让它支持函数调用、变量赋值、布尔表达式,甚至把它扩展成一个简单的解释器前端。你会发现,数据结构课程里每一个小小的实验题,铺展开来都能通向一个宏大的工程领域。从栈到括号匹配再到表达式求值,看似是两条孤立的路,它们最终在一棵树的根节点汇集,进入语言的殿堂。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询