关于二叉树重构的思索
我们在学习二叉树的遍历时,都会不可避免的学到二叉树的三种遍历方式,分别是遵循(根-左-右)的前序遍历、遵循(左-根-右)的中序遍历以及遵循(左-右-根)的后序遍历。并且每一个二叉树都可以用这三种遍历方式并且分别转换为字符串序列,以便在计算机上面保存。但是我们在进行逆向操作的时候却会遇到困难:我们并不能从某一种遍历方式推断出唯一的二叉树,也就是说,这是个单向编码的过程。而当我们有一个二叉树的两种遍历方式的表述时,似乎也不能做到尽善尽美:前序遍历和中序遍历的组合或者中序遍历和后序遍历的组合可以逆向生成唯一的二叉树,但是偏偏前序遍历和后续遍历却不可以。这其中的原因是什么呢?
1. 哈希函数与逆波兰表达式
A. 哈希映射
在Serverfault举办的一次解谜式游戏中,其中有一关的谜底是将下一关的序号转换成MD5码,之后替换掉原始URL的k值。此处我们可以通过谷歌找到一些加密文本为MD5的网站来进行加密。比如我们在网站中输入5,那么我们就会得到e4da3b7fbbce2345d7772b0674a318d5
这个值。由于MD5复杂的算法使得它曾经一度被认为是难以破解的。所以它曾被很多网站用于加密关键数据比如用户密码。也就是说网站的数据库中只保存了加密过的密码,这样即使黑客通过某种手段得到了整个数据库,也因为无法还原那串32位的“乱码”而对密码无从知晓。然而,在1996年,德国的密码学家Hans Dobbertin却发现了MD5加密算法的漏洞使得MD5从此作为保存密码的功能的算法被逐渐废弃。Hans Dobbertin发现的漏洞就在于,存在几个不同的原文(即未加密的文字),其通过MD5加密后得到的字符序列是相同的。这样的现象在密码学中叫做collision,也就是“碰撞”。正是由于这样的collision的发现,使得人们对MD5忧心忡忡,毕竟,现在甚至不需要知道你的原始密码,也许换几个其他字符,结果也能和你的密码一样进行登录。
其实,这样的collision的存在其实从一开始就是必然的。因为这个算法会将任意长度的字符串生成为一个32位的序列,也就是说,这个生成的字符串最多只有(26+10)^(32)种可能的情况。而将无穷可数的字符串都映射到这36^10个字符串中,依据鸽巢原理,必然就会存在一些字符串的映射值相同。只不过由于这个算法的复杂,我们不能从MD5逆推出原始数据的可能,而确实在应用中也没有找到不安全的例子,所以就这样“侥幸”地被用到了安全领域。
而类似于MD5加密所采用的这种将一个大的集合通过某种算法映射到一个小的集合当中的过程,就叫做一个“哈希映射”。熟悉数据结构的同学一定不会陌生,甚至只是接触过一些Java的同学也一定对hash这个词有一定的了解。在Java中,如果直接打印输出一个对象,在控制台中就会出现一个这个对象的id,而这个id就是由对象的hashCode方法生成的。Java也运用这个hash的方法来判断两个对象是否相等(有点像上文提到过的判断用户密码是否输入正确)。
从信息论的角度来看,一个任意长于32位的字符串通过哈希函数处理后,实质上是完成了一次信息压缩。而当处理之后的信息再解码时不能生成唯一的信息时,我们就发现这种压缩是有损的。因为当一个确定信息通过某种处理使它的不确定性增大时,其包含的信息量也就会减少。
B. 树的遍历与逆波兰表达式
到现在我们就会突然发现,其实对于一棵树的遍历也可以看做一次哈希映射。只不过这次我们是将一个具体的有特定结构的树映射为长度为树的节点个数的字符串,而我们的哈希函数就是同学们所熟悉的遍历顺序。比如我们可以构造这样一棵树A(B(D(G()())(H()()))(E()()))(C()(F(I()())())) (这种繁琐的二叉树表示法可参考这里)
其通过前序遍历这一哈希函数的处理,我们就可以得到这个字符串:ABDGHECFI。然而,当我们将这个前序遍历的字符串进行解码时,我们却发现无法结果并不是唯一的。比如,对于二叉树A(B(D(G()())(H()()))(E()()))(C()(F()(I()())))
当我们通过前序遍历进行哈希映射时,我们会得到同样的结果,也就是说,这两棵树在这个算法下发生了collision。而根据我们之前提到的说法,这说明我们的遍历算法是一个有损压缩的算法,一棵树在进行一次遍历时,其信息并不会完整地保留下来。那么究竟是什么信息被丢弃了呢?我们会发现,当一棵二叉树被按照“中-左-右”的方式遍历时,遇到最大的问题是当算法遇到一个并不存在的左子树或右子树时,算法本身并不会记录这种不存在的状况,而是选择忽略。而这样的信息却是包含在一棵树的结构中的。也就是说,这样的“忽略”正是导致信息丢失的关键。这样,我们就可以用一种新的遍历算法,只要标出忽略的位置(此处用·字符表示)就可以保存所有的信息。(请各位自行脑补算法)
这样,如果运行程序,那么我们就会得到它的前序遍历结果:ABDG··H··E··C·FI···。而这个结果是和原二叉树一一对应的。
在这个例子中,新的遍历算法产生的结果有19个字符,比原来的字符数多了一倍还多。如果不考虑还有更简洁的算法,我们就可以说原来的那种压缩算法实际上减少了整棵树所包含的近一半的信息量。而我们想要找回之前的算法所丢失的信息,也需要以更多的字符数(物理空间)为代价。
但是在这里很多同学都会想到一个反例,那就是逆波兰表达式。这是一种将操作数(operand)放在前方而将操作符(operator)放在操作数之后的表示方法。比如算术中缀表达式3 + 4
就可以表示成3 4 +
(是不是想起了Scheme:-)),这种表达式的优美之处就在于,它可以很方便的在计算机中通过栈实现计算,而对于人类来说,这种表达方式的简洁则在于它可以完全不用括号。比如中缀表达式(3 - 4) * 5
可以写为3 4 - 5 *
,而3 - 4 * 5
则会写为3 4 5 * -
。这样我们似乎又遇到一个难题,对于中缀表达式而言,每一位信息都是必须的(包括括号),但是为什么却能够在物理字符数减少的情况下实现语义上面的等价转换呢?
实际上,这里确实并没有发生神奇的信息量不变,而是有变化的。在进行逆波兰表达式的计算中,我们会发现逆波兰表达式忽略了运算符的优先级这一概念,而是强制使用“从左向右”这种方式进行解析。而这种“从左向右”解析信息的规则,正是逆波兰表式在暗中所添加的信息。也就是说,表面上看逆波兰表达式减少了信息量,可实际上却增加了新的规定解析规则的信息使之避免了优先级的问题。同样对于中缀表达式,我们也可以做这样的规定:不管符号的优先级,直接从左向右进行计算。这样,我们的中缀表达式也就没有了用括号的必要。
但是这样的表示法的缺点也是显而易见的,因为3 - (4 * 5)
似乎并没有办法在这种新规定下面表示出来,而在逆波兰表达式中却可以,所以同样的物理位似乎仍包含了不同的信息量。但是我们如果再去仔细看一看逆波兰表达式就会发现,他们所包含的信息位并不是相同的。在中缀表达式中,13 + 14
可以直接表示为13+14
,占用5个字符;而在逆波兰表达式中,我们却不可以简单的写为1314+
,因为我们无法判断前两个操作数的分界线在哪里,有可能是1+314
也可能是131+4
。所以要想区分开两个操作数,我们必须在之间加上一个空格,写作13 14+
,这样我们就需要6个字符来表示逆波兰表达式了。
C. 小结
现在我们就明白,在逆波兰表达式表示法中,虽然通过强制的更“简单”的运算顺序规定使得括号消失,却会同时制造新的混乱。而在树的遍历中,虽然表面上保存了一棵树的所有信息,但是其中的隐藏信息,比如这棵树的左节点是否存在等却没有在某种特定的遍历中得到体现。我们似乎看到了隐约的守恒,虽然我们到目前为止还无法量化。所以我们可以大胆猜测,即使某种表示方式会兼顾逆波兰表示法的无括号的简洁与中缀表达式的清晰或者某种遍历表示会用相同的字符数来完整的还原出一棵树,我们也不必过分兴奋于它的精巧,因为这样就势必会有更加复杂的解析规则。其中包含的信息量虽然也许不会复杂到和原来相等,但至少会没有我们理想状况下的那么好。
2. 信息冗余与疑虑
A. What makes a binary tree?
在我们学习数据结构的时候,我们必然会接触一个定理,就是给定一棵树的前序遍历和中序遍历或给定中序遍历和后序遍历即可还原出整棵树。有些老师还会提到如果只是给出前序遍历和后续遍历则不可以。在这里我们可以做一个小实验,如果给出前序遍历“ABDE”与后序遍历“DBEA”,我们可以来试着还原一下。还原后我们会发现,这棵树可以有两种合法情况:分别是A(B(D()())())(E()())
和A(B()(D()()))(E()())。
同样是两种不同的遍历顺序,为什么前序遍历却无法完全和后序遍历相补充,而为什么对于前序遍历和后序遍历,有的却又可以有唯一确定的值呢?
通过我们之前关于信息论的简单介绍,大家也应该能猜个大概。前序遍历和中序遍历、中序遍历和后序遍历之所以能够还原成唯一的二叉树,说明他们包含的信息已经足以覆盖这棵二叉树的全部信息。那么为什么前序遍历和后序遍历却不可以呢?这一点其实也比较好理解,有线性代数基础的同学肯定会熟悉这样一种现象,即列向量a与列向量b线性无关(即a、b之间无法通过线性变换互相表示),向量b与向量c线性无关,但是我们不能说明a与c也是线性无关的,他们之间不满足传递性。在a与c线性相关的情况下,a与c虽然并不相同,但是他们携带的信息却有很多重复。这样就造成了对于a、b、c三个不同的向量,其实他们所携带的信息只需要a、b两个向量以及一个其他的小于c所携带的信息的“量”就可以了,那个“量”也就是a、c之间不重复的部分。
我们还可以用集合来更通俗的解释一下,card(A)表示集合A的势(即A中元素的个数),我们知道有一个公式:
。之所以存在小于的情况,就是因为集合A中与B中有重复的元素,所以根据集合的单一性,重复的元素被只保留一份。
其实前序遍历和后序遍历不能组成一棵完整的二叉树的原因也在于此。如果我们试图去根据前序、中序以及中序、后序遍历的方式去还原一棵二叉树的时候,我们会发现每一位的数据都是有用的(大家的作业已经练得够多了吧)。以前序、中序为例,我们先用前序最前面的元素确定根节点,再到中序中找到,以此确定了左右子树的范围,之后对子树递归调用此过程。在这个过程中,由于本文第一节提到的左叶右叶可能为空,用找到根节点把整个子序列分为三段(左边为一段,节点本身为一段,右边为一段)的方法,就可以很清楚的确定左边、右边分别有哪些元素。而对于前序、后序遍历,我们在执行第一步的时候就已经会发现它的破绽了。但我们查看前序遍历的第一位时,它对应代表着整棵树的根节点。但是如果我们据此去看后序遍历的最后一个节点时,会发现它也必然会是整棵树的根节点。也就是说,我们在这个过程中并不存在“找”的过程,因为后序遍历的最后一个节点已经和前序遍历的第一个节点相同了。这样,虽然这三种遍历组合方式有着同样的字符量,但是因为前序、后序遍历存在着信息冗余(即信息上的重复),所以他们包含的有用信息其实并没有那么多。这样,如果前序、中序遍历恰好能够等价于一颗完整的二叉树,其中的信息不多也不少,那么相对其少一些信息量的前序、后序遍历,自然也就无法包含完整的二叉树信息了。
B. 仍有疑虑的想法
但是这种解释并不是完备的。因为我们可以发现并不是所有的前序、后序遍历均不能生成唯一的二叉树。比如前序遍历为ABCDE,后序遍历为CDBEA的树有且仅有一个:A(B(C()())(D()()))(E()())。要求这棵树的遍历还原问题实际上就可以简化为求前序为BCD后序为CDB的树的还原。我们稍微分析一下就会发现它的树是唯一的,因为C确定了左子树的结束位置,而这个左子树的元素个数恰好是一个,这就确定了这棵树的唯一性。如果我们把后序改为DCB,那么结果将大为不同,我们的树就又恢复到了不确定的状态。
至此,我们知道即使在前序、后序遍历中损失了一些信息,我们仍然可以在一些特殊的情况中得到完整的树。这就是说,也许在前序中序,或者中序后序遍历中,信息也存在着一定的冗余。只不过这种冗余以一种不易察觉的方式存在。当然也许还有一种可能,那就是用这种想法来理解二叉树一开始就不靠谱。这倒不是信息论本身的问题,而是信息是否以在这片文章我猜测的方式存在着信息量与物理上面的严格对应。因为我本来就没有信息论的基础,多次google后也没有找到满意的答案,所以只能作此很有限的猜测和推理。
C.小结
其实之前一直没有想到会遇到最后的这个问题,但是遇到之后才发现也许信息本来就是一种复杂的东西。就拿我们的某个遍历产生的字符串来说,它自身的信息就是各个字母的排列,但是这些排列之中却有着太多的含义。有很多时候我以为自己已经从单一的字符串中挖掘出了足够的信息,但是当有另一个字符串、另一个遍历顺序时,我发现它们之间的共同点和差异却体现在我原来并没有考虑到的那一部分中。信息论看似诱人,似乎揭示这某种本质性的东西,但是我们怎么能够保证我们已经对一段信息有着完全的把握,这种已经把握的本质之下没有存在这更深刻的本质?而在关于最后信息和物理位之间的关系,我一直没有找到合适的资料,也许是我的查找范围不够,也许是以前就有人意识到这是个很复杂的问题。但无论如何,我们都已经经历了以一个新的视角来看待这种很基本的数据结构的过程,而我自己在学习过程中,也一直喜欢以更宏观的视角来把握,这样的里外融合的感觉也是很美好的。
参考:http://www.th7.cn/Program/cp/201501/351053.shtml,转载请注明出处.