阅读 235

《计算机程序设计艺术》数据结构之树

我的掘金专栏

上节是线性结构,这节的树是「非线性」结构。

树的定义

  1. 有一个特别指定的节点 T,记为 root(T),它叫做树的根。
  2. 剩余的节点被分划成 m>=0 个「不相交」的集合,T1,...,Tm,每个集合也是一个树,称为子树。

这个定义很奇怪,它是一个递归定义,因为用树来定义了树。

但这并没有什么问题,因为让 m = 0,那么一个节点就是一棵树。然后我们可以用若干个这样的树组成更大的树。

树也有非递归形式的定义,但递归定义似乎是最合适的。因为自然界中的树也是由幼树的树芽逐渐长出有自己的树芽的子树芽。

术语

  • 度:一个节点的子树的个数,叫做该节点的度(是枝开二度类比来的吗?)
  • 叶子节点:度数为零(没开过)的节点叫做叶子节点
  • 分支节点:非叶子节点
  • 级:root(T) 的级为 0,它包含的子树的根节点都比 root(T) 的级别大 1,以此类推。

  • 有序树:如果子树 T1,...,Tm 的相对顺序是重要的话,我们就说该树是有序树。也有人称之为「平面树」,因为它与在一个平面中嵌入树的方式有关。T1 叫第一棵子树,T2 叫第二棵子树等等。
  • 有向的树:如果不把仅仅是节点子树的相对次序不同的两个树当作不同的树,就说这样的树是有向的。因为考虑的仅仅是节点相对的方向,而不是它们的次序。(这个定义很晦涩,学了图之后有更简单的定义)。

从现在开始,我们假定所有的树都是有序的,除非另有说明。

比如下面两棵树是不同的树:

  • 森林:零个或多个不相交的树的集合。也可以说树除了根之外的所有节点形成一个森林。
  • 二叉树:该树的每个节点至多有 2 个子树。当只有一个子树时,我们会区分左子树和右子树。

注意:二叉树不是树的特殊情况。二叉树完全是另一个概念,虽然它跟树有很多联系。例如下图:

对于二叉树,这是两棵不同的树;对于树,这两个是相同的树。(在这一点上我个人表示不太理解,对于树来说它们可以是不同的树哇)。

  • 0-2树:每个节点恰有0个或2个子树的树。

树的画法

画树的时候,可以把根画在下面,也可以把根画在上面,甚至左边。

最推荐的画法是把根画在上面,原因如下:

  1. 符合阅读习惯,大部分人都是从上往下阅读
  2. 符合更多术语的意义,如 subtree、supertree 等。
  3. 方便使用深浅来描述节点。深的在下面,浅的在上面。

还可以用很多其他方式来表示树。

上图可以表示为下面三种不同的形式:

一个代数公式也定义了一个隐含的树,a - b * (c/d + e/f) 表示:

遍历二叉树

有三个主要的方法遍历二叉树:

  1. 先根序
  2. 中根序(也叫对称序)
  3. 后根序

中根序遍历

设 T 存储了二叉树的节点的地址(头指针),A 是一个栈,作为辅助。步骤如下:

  • 第一步,置栈 A 为空,置 P <- T
  • 第二步,如果 P 为空,则跳到第四步;如果 P 不为空,则跳到第三步。
  • 第三步,置 A <= P(把 P 压入 A),然后置 P <- LeftLink(P),并返回至第二步。
  • 第四步,如果 A 不为空,则置 P <= A(弹出栈顶内存放入 P);如果 A 为空,则算法结束
  • 第五步,访问 Node(P),然后置 P <- RightLink(P),并返回至第二步。

用人类的话来说,就是

  1. 把所有最左边的节点依次压入 A 栈,直到没有最左的节点。
  2. A 栈依次弹栈入 P,打出 P 节点的值,再看 P 有没有右子树,如果有,就让右子树经历一下第 1 步。没有右子树就算了。
  3. A 栈为空则说明打印完毕。

新手乍看会觉得奇怪,这只是先遍历了左边,再遍历了右边而已啊,哪有在中间访问父节点?

这个就需要新手自己动手模拟一遍整个过程了,确实有在「中间」访问父节点哦。

可以用几乎相同的算法来描述先根序遍历,后根序稍微难一点,所以一般来说我们优先使用前两种遍历方式。

前驱与后继

前驱就是上一个节点(可以是子节点也可以是父节点),后继就是下一个节点。由于我认为这两个术语十分无聊,所以下文统一以大白话来叙述。

假设 P 指向一个二叉树,那么

  • P$ 表示在中根遍历时节点 Node(P) 的下一个节点的地址
  • $P 表示在中根遍历时节点 Node(P) 的上一个节点的地址

为什么用 $ 呢?因为 $ 是对称的,所以可以表示对称序(中根序)。

  • P* 表示在先根遍历时节点 Node(P) 的下一个节点的地址
  • *P 表示在先根遍历时节点 Node(P) 的上一个节点的地址

后根就不讲了,免得记混。

内存表示

我们可以把二叉树中的每个节点表示为 [LeftLink, Node, RightLink],那么树

就可以表示为:

这种表示方法会出现很多空链接,空链接甚至比有用的链接还要多,怎么解决这个问题呢?

穿线的树

看图:

  1. 每个节点都有两条指向其他地方的线
    1. 可能两条线都是实线,指向自己的子树,比如 C
    2. 可能两条线一实一虚,一个是子树,一个是比它高的节点(规则不明),比如 B 和 E
    3. 可能两条线都是虚线,比如 D、G、H、J
  2. 只有 D 和 J 的虚线比较特殊,分别指向「最左」和「最右」,后面再说。

那么虚线具体指向哪一个比它高的节点呢?答案是

  • 左虚线指向中根遍历时的上一个节点
  • 右虚线指向中根遍历时的下一个节点

重新开图,可以得出中根中序遍历的顺序是:

D B A E G C H F J

好了,我们知道怎么画穿线了,那么怎么用内存表示呢?

给每个节点再加两个 bit:[LeftTag, LeftLink, Node, RightLink, RightTag]

LeftTag 和 RightTag 的值只能是 0 或 1,0 表示没有虚线(有实线),1 表示有虚线。

因为我们可以通过中根遍历算法确定具体的位置,所以就不需要再存一个地址了。

穿线的存在使得遍历二叉树不需要额外的辅助栈。

相似和等价

  • 相似的树:如果二叉树 T 和 T' 结构相同,则它们是相似的
  • 等价的树:如果二叉树 T 和 T' 相似,且对应的节点包含相同的信息,则说它们是等价的。

书中对如何证明两个数相似和等价给出了形式化的步骤,有兴趣可以自行翻开 311 页查看。

树的二叉树表示

树和二叉树的区别我们复习一下:

  1. 树总是有一个根节点;树的每个节点可以有 0,1,2,3,4,5,6,... 个子树。
  2. 二叉树可以为空;它的每个节点可以有 0,1,2 个子二叉树;它区分左儿子和右儿子。

这一节研究「我们是否能把一棵树表示为一棵二叉树」。

考虑有如下两棵树

我们把每棵树的链接删掉,然后把所有拥有同一个父亲的节点横向链接(哥哥指向弟弟),然后让每个父亲只链接它的大儿子(爸爸指向大儿子),再把两个根节点连起来,就得到了这样的图:

接下来就神奇了,把上图顺时针转 45 度,然后微调一下,就得到了二叉树!

这个过程是可逆的,所以任何二叉树都对应于唯一片森林。

为什么会这样呢?因为每个节点至多有一个大儿子,至多有一个弟弟呀,所以每个节点的度至多为 2,这满足二叉树的定义。

通过数学归纳饭,很容易证明三棵树的森林,也可以转化成二叉树。以此类推。

这个转换过程叫做「森林和二叉树之间的自然对应」。

形式化的描述如下:

对于具有 n+1 个节点的树 T,和具有 n 个节点的二叉树,令 F = (T1, T2, ..., Tn) 是 T 的子树组成的森林,二叉树 B(F) 的定义如下:

  1. 如果 n = 0,则 B(F) 为空
  2. 如果 n > 0,则 B(F) 的根是 root(T1);B(F) 的左子树是 B(T11,T12,...,T1m),其中 T11,T12,...,T1m 是 T1 的子树;B(F) 的右子树是 B(T2,T3,...,Tn),这里又是一个递归定义。

重新理解遍历

自然对应转行过程其实就是对树进行先根遍历。

我们把上面的两棵树用另一种形式重新表示一下:

(A(B,C(K)), D(E(H),F(J),G))

对其进行先根遍历,得到 A B C K D E H F J G,居然就是把括号去掉而已!

这就是为什么把这种遍历叫做「自然对应」了,因为真的很自然。

再举个例子:

对书的章节号组成的树进行先根遍历,得到的结果是

1
1.1
1.1.1
1.1.2
1.2
1.2.1
1.3
1.3.1
1.3.2
复制代码

够不够自然?

再举个例子,欧洲的王位继承。

  1. 国王死了,传位给大儿子,大儿子死了就传位给大儿子的大儿子。
  2. 如果很不幸,大儿子全家都死了,就传给其他儿子中最大的一个。以此类推。

所以,只需要把皇室的族谱先根遍历一下,就可以得到王位继承顺序了。

够不够自然?

所以先根序是列出树中节点最自然的方法。

接下来是后根序。

后根序

(A(B,C(K)), D(E(H),F(J),G))

的先根序是

A B C K D E H F J G

后根序则是

B K C A H E J F G D

这其实就是把原树 (A(B,C(K)), D(E(H),F(J),G)) 的书写方式改一下,把父节点写在括号右边,而不是左边:

((B, (K)C)A, ((H)E,(J)F,G)D)

通过把对应的定义做对比,可以观察出:

  1. 先根遍历森林与先根遍历对应二叉树完全相同
  2. 后根遍历森林与中根遍历对应二叉树完全相同

树的基本数学性质

扩充的二叉树

在二叉树所有的空节点的地方加上特殊节点,就得到了一个扩充的二叉树。

假设有 n 个圆圈节点,s 个方形节点,那么边的数量就是 n + s - 1

由于每个圆圈对应两条边,所以边的数量也可以表示成 2n

于是我们得到 n + s - 1 = 2n,由此推出 s = n + 1

s = n + 1 意味着,特殊的方形节点总是比圆形节点多一个。

即使 n = 0,这个公式也成立。

通路长度

图:一般把图定义为点(叫做顶点) + 连接某些不同顶点的线(叫做边)的集合。简而言之,图 = 顶点 + 边。一对顶点之间最多有一条边。如果两个顶点被一条边连接,则称这两个顶点是「相邻」的。

通路:如果 VV' 是两个顶点,V_0 = VV_k 相邻与 V_{k+1}V_n = V',那么我们就说 (V_0, V_1, ..., V_n) 是从 V 到 V' 的一条通路,长度为 n。

简单通路:如果 V_0, V_1, ..., V_{n-1} 都不相同,而且 V_1, V_1, ..., V_n 也都不相同,则这条通路的简单的。这句话的意思是 V_0V_n 可以相同,只要中途的点都不相同即可。

扩充二叉树的外部通路长度 E 的定义是「从根到每个方形节点的通路长度之和」。

扩充二叉树的内部通路长度 I 的定义是「从根到每个圆形节点的通路长度之和」。

上图中的 E = 3 + 3 + 2 + 3 + 4 + 4 + 3 + 3 = 25I = 2 + 1 + 0 + 2 + 3 + 1 + 2 = 11

并且可以证明,E = I + 2n

完全二叉树

图中从左到右给所有节点编了号。你很容易发现

  • 节点 k 的父亲是 k/2
  • 节点 k 的儿子是 2k 和 2k+1
  • n 是圆形节点个数,那么方形节点的编号就是 n + 1 到 2n + 1

因此,完全二叉树可以顺序存储,且结构隐含在节点的下标中。

带权通路

假设有 m 个实数 w_1 w_2 ... w_m,把这些实数作为扩充二叉树的方形节点,可以画出不同的扩充二叉树。求如何找出「带权通路长度」最小的二叉树(其中带权通路长度 \Sigma w_jl_j 是指 w_j 乘以其到根节点的通路长度 l_j 的积的累加之和)。

假设这 m 个数字是 2 3 4 11,那么就有三种扩充二叉树:

三者的带权通路长度为别是 4*2+2*3+3*3+11*1=343*2+4*3+11*3+2*1=532*2+11*2+3*2+4*2=40。最小的是第一个。

求最小带权通路长度的树的漂亮算法是由 D.Huffman(哈夫曼)发现的。

  1. 首先求最小的两个值 w_iw_j 组成的二叉树(显然一个放左边一个放右边长度最小)
  2. 然后把这个数看做一个整体,值为 w_i + w_j,与剩下的数字放一起,递归地带入第 1 步即可,直到只剩两个

举例:求 2,3,4,7,11,13,17,19,23,29,31,41 的最优树

关注下面的标签,发现更多相似文章
评论