11.1 再谈树
在第6章中,我们第一次接触到二叉树;后来,又接触到了其他树状结构,如解答树、BFS树。本节将继续讨论“树”这一话题。
有n个顶点的树具有以下3个特点:连通、不含圈、恰好包含n-1条边。有意思的是,具备上述3个特点中的任意两个,就可以推导出第3个,有兴趣的读者不妨试着证明一下。
11.1.1 无根树转有根树
图11-1 无根树转有根树
输入一个n个结点的无根树的各条边,并指定一个根结点,要求把该树转化为有根树,输出各个结点的父结点编号。n≤106,如图11-1所示。
【分析】
树是一种特殊的图,因此很容易想到用邻接矩阵表示。可惜,n个结点的图对应的邻接矩阵要占用n2个元素的空间,开不下。怎么办呢?用vector数组即可。由于n个结点的树只有n-1条边,vector数组实际占用的空间与n成正比。
vector<int> G[maxn];
void read_tree() {
int u, v;
scanf("%d", &n);
for(int i = 0; i < n-1; i++) {
scanf("%d%d", &u, &v);
G[u].push_back(v);
G[v].push_back(u);
}
}
转化过程如下:
void dfs(int u, int fa) { //递归转化以u为根的子树,u的父结点为fa
int d = G[u].size(); //结点u的相邻点个数
for(int i = 0; i < d; i++) {
int v = G[u][i]; //结点u的第i个相邻点v
if(v != fa) dfs(v, p[v] = u); //把v的父结点设为u,然后递归转化以v为根的子树
}
}
主程序中设置p[root] = -1(表示根结点的父结点不存在),然后调用dfs(root, -1)即可。初学者最容易犯的错误之一就是忘记判断v是否和其父结点相等。如果忽略,将引起无限递归。
11.1.2 表达式树
图11-2 表达式树
二叉树是表达式处理的常用工具。例如,a+b*(c-d)-e/f可以表示成如图11-2所示的二叉树。
其中,每个非叶结点表示一个运算符,左子树是第一个运算数对应的表达式,而右子树则是第二个运算数对应的表达式。如何给一个表达式建立表达式树呢?方法有很多,这里只介绍一种:找到“最后计算”的运算符(它是整棵表达式树的根),然后递归处理。下面是程序:
const int maxn = 1000;
int lch[maxn], rch[maxn]; char op[maxn]; //每个结点的左右子结点编号和字符
int nc = 0; //结点数
int build_tree(char* s, int x, int y) {
int i, c1=-1, c2=-1, p=0;
int u;
if(y-x == 1){ //仅一个字符,建立单独结点
u = ++nc;
lch[u] = rch[u] = 0; op[u] = s[x];
return u;
}
for(i = x; i < y; i++) {
switch(s[i]) {
case '(': p++; break;
case ')': p--; break;
case '+': case '-': if(!p) c1=i; break;
case '*': case '/': if(!p) c2=i; break;
}
}
if(c1 < 0) c1 = c2; //找不到括号外的加减号,就用乘除号
if(c1 < 0) return build_tree(s, x+1, y-1); //整个表达式被一对括号括起来
u = ++nc;
lch[u] = build_tree(s, x, c1);
rch[u] = build_tree(s, c1+1, y);
op[u] = s[c1];
return u;
}
注意上述代码是如何寻找“最后一个运算符”的。代码里用了一个变量p,只有当p=0时才考虑这个运算符。为什么呢?因为括号里的运算符一定不是最后计算的,应当忽略。例如,(a+b)*c中虽然有一个加号,但却是在括号里的,实际上比它优先级高的乘号才是最后计算的。由于加减和乘除号都是左结合的,最后一个运算符才是最后计算的,所以用两个变量c1和c2分别记录“最右”出现的加减号和乘除号。
再接下来的代码就不难理解了:如果括号外有加减号,它们肯定最后计算;但如果没有加减号,就需要考虑乘除号(if(c1<0) c1 = c2);如果全都没有,说明整个表达式外面被一对括号括起来,把它去掉后递归调用。这样,就找到了最后计算的运算符s[c1],它的左子树是区间[x, c1],右子树是区间[c1+1, y]。
提示11-1:建立表达式树的一种方法是每次找到最后计算的运算符,然后递归建树。“最后计算”的运算符是在括号外的、优先级最低的运算符。如果有多个,根据结合性来选择:左结合的(如加、减、乘、除)选最右边;右结合的(如乘方)选最左边。根据规定,优先级相同的运算符的结合性总是相同。
例题11-1 公共表达式消除(Common Subexpression Elimination, ACM/ICPC NWERC 2009, UVa12219)
可以用表达式树来表示一个表达式。在本题中,运算符均为二元的,且运算符和运算数均用1~4个小写字母表示。例如,a(b(f(a,a),b(f(a,a),f)),f(b(f(a,a),b(f(a,a),f)),f))可以表示为图11-3(a)中形式。
用消除公共表达式的方法可以减少表达式树上的结点,得到一个图,如图11-3(b)所示。左图有21个点,而右图只有7个点。其表示方法为a(b(f(a,4),b(3,f)),f(2,6)),其中各个结点按照出现顺序编号为1,2,3,…,即编号k表示目前为止写下的第k个结点。
| ![]() |
| (a) | (b) |
图11-3 公共表达式消除
输入一个长度不超过50000的表达式,输出一个等价的,结点最少的图。
【分析】
算法的第一步是构造表达式树。接下来应该怎么做呢?是否可以用两两比较的方法去掉重复?比较两棵树的时间复杂度为O(n)(因为要递归比较二者的所有后代),再加上二重循环枚举两棵子树,总时间复杂度高达O(n3),无法承受。此处不仅需要更快地比较两棵树,还需要更快地查找一棵树是否存在过。
图11-4 子树缟写
借用第5章“集合栈计算机”的思路,用一个map把子树映射成编号1, 2,…。这样一来,子树就可以用根的名字(字符串)和左右子结点编号表示。如图11-4所示,用(a,0,0)表示根的名字为a,且左右子结点均为空(0表示不存在)的子树,即叶子a。可以看到,下面所有叶子a的编号都是4。再例如,(b,3,6)就是根的名字为b,左右两个子树的编号分别为3,6。可以看到,这样的子树编号均为5。
这样,每次判断一棵子树是否出现过只需要在map中查找,总时间复杂度为O(nlogn)。