Appearance
2017·41 表达式树转中缀:中序遍历+加括号,难点不在效率在细节
本文属于「从暴力解到最优解」专题的第二组。第一组讲的是「怎么把暴力解优化掉」;第二组反过来,讲怎么认出一道题根本不用优化——直观写法就是满分解,力气要花在别的地方。
题目
请设计一个算法,将给定的表达式树(二叉树)转换为等价的中缀表达式(通过括号反映操作符的计算次序)并输出。例如,两棵表达式树 *(+(a, b), *(c, -(_, d))) 和 +(*(a, b), -(_, -(c, d))) 作为算法输入时,输出的中缀表达式分别为 (a+b)*(c*(-d)) 和 (a*b)+(-(c-d))(单目运算符 - 取负,在树里以左子树为空、右子树为操作数表示)。二叉树结点定义如下:
c
typedef struct node {
char data[10]; // 存储操作数或操作符
struct node *left, *right;
} BTree;要求:(1) 给出算法的基本设计思想;(2) 根据设计思想,采用 C 或 C++ 语言描述算法,关键之处给出注释。总分 15 分。
先注意一件事:题面没有出现「尽可能高效」。按总纲的分类,这属于第二组——没有悬在头顶的最优性要求。但这不等于随便写,下面会看到力气该花在哪。
直观解法:中序遍历顺手加括号
中缀表达式天生就是表达式树的中序遍历——左子树、根、右子树,正是我们平时手写算式的顺序。唯一要补的是括号:a+b*c 和 (a+b)*c 的中序遍历都是 a+b*c,树结构不同、输出却可能撞车,所以每一棵非叶子树都要用括号把自己「封装」起来,才能无歧义还原运算次序。最直接的想法是递归时带一个 depth 参数,进操作符结点时按 depth 是否为根决定加不加括号:
c
typedef struct node {
char data[10];
struct node *left, *right;
} BTree;
// depth: 当前结点在树中的递归深度,根为 0
static void traverse(BTree *node, int depth, char *buf) {
if (node == NULL) return;
int isLeaf = (node->left == NULL && node->right == NULL);
if (isLeaf) {
strcat(buf, node->data); // 操作数直接输出
return;
}
if (depth > 0) strcat(buf, "("); // 非根的操作符,左侧加 (
if (node->left != NULL) traverse(node->left, depth + 1, buf);
strcat(buf, node->data); // 输出操作符
traverse(node->right, depth + 1, buf);
if (depth > 0) strcat(buf, ")"); // 非根的操作符,右侧加 )
}
void toInfix(BTree *root, char *buf) {
buf[0] = '\0';
traverse(root, 0, buf);
}复杂度:每个结点访问一次、strcat 单次追加是常数次字符操作,时间
为什么它就是满分解
这道题没有「暴力 vs 最优」的分差,因为这个
- 有没有重复扫描? 没有。每个结点只在中序遍历经过它的那一刻被访问一次,操作符要不要加括号、操作数直接输出,都是当场判断当场写,不需要事后回头改。
- 有没有重算已知的东西? 没有。深度作为递归参数顺路传递,不需要为某个结点重新计算它在树里的位置。
- 有没有放着性质不用? 没有。题目没给额外的结构性质(比如运算符优先级表)要求你去利用——它反而明确要求「用括号反映计算次序」,也就是干脆放弃优先级判断,用括号把每层结构原样封起来,这本身就是最直接的做法。
更根本的一条:要把树的结构信息(谁先算、谁后算)完整还原成一条线性字符串,就必须至少访问树中每一个结点一次——这是
这也是第二组题的共同特征:当输出必须完整反映输入结构时,"优化"这个动作根本不存在。认出这一点,就不会在考场上为不存在的分数去冒险写复杂代码。
真正的难点在括号与单目负号
分不在优化上,那在哪?在这道题最容易失手的三处易错细节:
- 只有非根结点才加括号:整个表达式的最外层不需要括号(题面给的两个样例都没有最外层括号)。用
depth > 0判断——根的 depth 是 0,天然跳过加括号这一步;如果一上来就不分青红皂白地给根也加括号,整条表达式会被多包一层,与标准答案不符。 - 单目负号
-靠"左子树为空就跳过"自动处理,不需要额外分支。题面约定单目-的左子树为空、右子树是操作数。递归时if (node->left != NULL) traverse(...)这一行本身就会在左子树为空时什么都不做,紧接着输出-、再递归右子树,自然拼出(-X)这样的形态——不需要专门写一个if (isUnaryMinus)分支去特殊处理它。 - 括号必须成对,进出对称:加左括号发生在"进入非叶非根结点、递归左子树之前",加右括号发生在"递归完右子树之后、即将返回之前"——一进一出严格配对。哪怕只是把
if (depth > 0) strcat(buf, ")")这一行漏掉或错放了位置,输出的括号就会多一只或少一只,整条表达式作废。
三处任何一处出错,代码依然「跑得动」、能输出一串字符,但字符串本身是错的或有歧义的——这正是它们比空指针段错误更隐蔽、更容易在考场上被忽略的原因。
中序遍历拼中缀,看一遍
用题面给的第一棵树 *(+(a, b), *(c, -(_, d))),期望输出 (a+b)*(c*(-d)):
演示为 JavaScript 版本,逻辑与上文 C 代码一一对应。
别画蛇添足
第二组题的另一半功夫,是忍住不去做多余的事。这道题最容易出现的两个「过度设计」陷阱:
- 给叶子也加括号。看到「非叶结点要加括号」,容易联想成「统一格式,干脆所有结点都加一层括号」,写出
(a)+(b)这样的结果。但题面样例里操作数从来都是裸露输出的,叶子判定要放在"要不要加括号"之前直接短路返回,不参与括号逻辑。 - 一上来就给根加括号。为了让代码"看起来更整齐",把
if (depth > 0)的判断去掉、所有层级一视同仁地加括号,结果整条表达式被多包一层最外层括号,与题面给的两个样例都对不上。根不加括号是判分点之一,不能省。
这两个陷阱的共同点是:都源于想让代码"看起来更一致、更保险"的冲动,但表达式树的括号规则本来就是不对称的——非根才需要、叶子完全不需要。把这份不对称原样写出来,比强行统一格式更接近满分。
下一步
- 在 OJ 上验证你的写法:ds-2017-41 表达式树转中缀
- 回到题库看这道题的完整解析与错因:真题库 · 数据结构