二叉树
前序遍历(递归及递推算法)
霍夫曼编码深入研究
上面两节内容中,我们讨论了广义树的两种实现方法,及“子节点表”和“最左子节点/右兄弟节点”法。这两种方法所实现的树是多叉树,适用于描述任意的树形结构。本节内容中我们将讨论一种特殊的树,即二叉树。与广义树相比,二叉树具有特定的结构,包括内外节点个数的关系、节点数与树的高度的关系等。本节将着重讨论二叉树的各种遍历算法,包括前序、中序、后序和层序遍历算法。最后,本章将研究一种在信息技术中广泛应用的一种特殊的二叉树,即Huffman树。
二叉树的数学性质
在讨论二叉树的各种算法之前,我们先详细讨论一下二叉树的数学性质。
性质X.1 一棵有N个内部节点的二叉树有N+1个外部节点。
证明:归纳法证明
当N=0时,一颗有0个内部节点的二叉树有1个外部节点,所以N=0时性质是成立的。
当N>0时,假设任意一棵有M(M<N)个内部节点的二叉树有M+1个外部节点。任何有N个内部节点的二叉树,有k个内部节点在它的左子树上,有N-1-k个内部节点在其右子树上,还有一个是根节点自身,其中k介于0与N-1之间。通过归纳假设,左子树有k+1个外部节点,右子树有N-k个外部节点,总计N+1个外部节点。
■
性质X.2 一棵有N个内部节点的二叉树有2N个链接:N-1个链接到内部节点,N+1个链接到外部节点。
证明:在有根树中,除根节点之外的任一节点都有一个父节点,也链接至其父节点的边,所以有N-1个链接与内部节点连接。根据性质1,树中有N+1个外部节点,每个外部节点都有一个父节点,所有有N+1个链接与外部节点连接。
■
定义 在一棵树中节点的层数比其父节点的层数高一层,根节点在第0层。树的高度为树中节点层数中最大值。树的路径长度是所有树节点的层数的总和。一棵二叉树的内部路径长度是树的所有内部节点的层数总和。一棵二叉树的外部路径长度是树的所有外部节点的层数总和。
性质X.3 任何带有N个内部节点的二叉树的外部路径长度比内部路径长度大2N。
证明:二叉树的创建过程:从包含一个外部节点的二叉树开始,重复以下步骤N次:
挑选一个外部节点,并把两个外部节点作为子节点的一个新内部节点来代替该外部节点。
如果所选的外部节点的层数是k,由于增加了一个层数为k的内部节点,所以内部路径长度增加了k;同时去掉了一个层数为k的外部节点、增加了两个层数为k+1的外部节点,这导致外部节点路径增加k+2。
二叉树创建的整个过程从内部路径和外部路径都为0开始,对于N个步骤的每一步,外部路径长度比内部路径长度多增加2,所以总计2N。
■
性质X.4 带有N个内部节点的二叉树的高度至少是lgN,至多是N-1。
证明:高度最高的情况是只有一个叶节点的退化树,N-1个链接从根节点到叶节点,最短的情况是除底部层数外的每一个层数为i的层上,有2i个内部节点,如果高度为h,因为有N+1个外部节点,那么有如下关系:
2h-1 < N + 1 ≤ 2h
所以最少的高度为向上取整所得到的整数lgN。
■
性质X.5 带有N个内部节点二叉树的内部路径长度至少是Nlg(N/4),至多是N(N-1)/2。
证明:内部路径最大和最小情况与性质4中的讨论相同,在最大的情况下,树的内部路径长度为0+1+2+…+(n-1)=N(N-1)/2。
最小的情况,有N+1个外部节点,树的高度不超过[lgN],则外部路径长度界限为(N+1)[lgN],根据性质3,内部路径长度界限为(N+1)[lgN]-2N < Nlg(N/4)。
■
二叉树广泛应用于计算机技术中,而且当二叉树完全平衡或接近平衡时,其性能最佳。在系列讲解搜索算法时我们将发现平衡树的性能。
树的基本性质为我们开发一些用于求解实际问题的高校算法提供了信息。对于我们将要遇到的特定算法的更详细的分析,需要用到非常复杂的数学工具,有兴趣的读者可以参考相关的书籍,接下来我们将重点放到二叉树相关的算法上。
二叉树的遍历
在本节我们将主要考虑树的遍历算法,即给定一棵树,目标能系统地访问每一个节点。在链表等数据结构中访问每一个节点时,我们可以根据链表的特定结构性一次访问每一个节点,但是在二叉树结构中,由于每个节点可能会与两个节点有连接关系,对这一点我们需要确定访问的规则。
在前几节内容中,我们讨论广义树的遍历算法时,已经涉及了广义树前序和后序遍历算法,我们也总结道在广义树中前序遍历算法更切合我们的实际认知。在这里我们将具体讨论二叉树的前序、中序、后序以及层序遍历算法。
前序(preoeder) 我们访问该节点,然后再访问该节点的左子树和右子树;
中序(inorder) 我们访问该几点的左子树,然后访问该节点,最后在访问该节点的右子树;
后序(postorder) 我们访问该节点的左子树和右子树,然后访问该节点;
层序(levelorder) 我们逐层自左向右访问每个节点。
我们可以很容易地用递归程序实现这些算法,在这里我们将分别用递归和非递归方法实现各种遍历算法。
二叉树树节点数据结构
在具体讨论树的遍历算法之前,我们首先讨论一下二叉树节点的设计,二叉树的每个节点包含了节点内容,即元素项,此外还有指向左子节点和右子节点的指针。
需要注意的是,我们在后面将介绍一种特殊的二叉树,即二叉搜索树,其结构特点是节点的元素项大于左子树中任意节点的元素项,同时小于右子树中的任意节点的元素项。但在这里我们将讨论的二叉树的元素之间没有这种结构性,所以在这里我们在创建一个二叉树时,跟前两节讨论广义树时相同,采用人工添加的方式。
二叉树节点的数据结构实现如下:
前序遍历
前序遍历算法发首先访问节点自身,然后一次访问节点的左子树和右子树。从递归的角度来定义这个过程,可以定义如下:如果节点不是空节点,则首先访问节点自身,然后递归地访问节点的左子树和右子树。在第前面章节中,我们讨论栈结构时,已经讨论了递归算法的等效递推实现过程,即可以通过栈保存当前程序的参数,转向另一段程序,在完成之后再弹出栈顶参数,继续执行程序。下面将首先以二叉树的前序为例讨论这一过程。
图中为一棵二叉树,前序遍历树中各节点,访问个节点的元素项,其结果将是:
1→2→4→6→10→-1→7→5→8→9→3→14→11→12→13
采用递归算法实现前序遍历时,首先用节点指针rt指向二叉树的根节点,然后递归遍历二叉树中所有节点的步骤如下:
在执行时,首先访问节点n14,然后递归地前序遍历n14的左子树,即以n8为根节点的子树;首先访问节点n8,然后递归遍历n4为根节点的子树;首先访问节点n4,然后递归遍历以n2为根节点的子树;首先访问节点n2,然后递归遍历以n1为根节点的子树;首先访问节点n1,由于n1的左子树为空,所以紧接着访问n1的右子树,该子树为单节点树,所以直接访问节点n0后便结束以n1为根节点的子树的遍历;此时回溯到节点n2的右子树,该子树为空,故以n2为根节点的子树遍历结束;程序回溯至以n4为根节点的子树,递归遍历以n3为根节点子树,该子树为单节点树,所以直接访问节点n3后便结束以n4为根节点的子树的遍历;程序回溯至以n8为根节点子树,递归遍历以n7为根节点的子树,通过相同的分析可知,该子树的遍历顺序为:n7,n5,n6;此时根节点n14的左子树遍历完成,程序回溯至根节点n14,递归遍历n14的右子树,即以n13为根节点子树,通过相同的分析可知,该子树的遍历顺序为:n13,n12,n11,n9,n10。我们可以通过下面讨论的基于栈结构的迭代遍历算法加深对这一过程的理解。
如果采用非递归算法实现二叉树的前序遍历,需要借助于栈结构。其步骤如下:
如果根节点rt为空,则返回;否则,首先将根节点压入栈中,然后迭代执行以下步骤:
1. 弹出栈顶存放的节点n,访问该节点;
2. 依次将n的右子节点和左子节点压入栈中;
3. 如果栈不为空,则返回步骤1继续执行,否则结束迭代。
其中步骤1为节点访问操作;步骤2中先将右子节点压入栈中然后再将左子节点压入,这是因为在栈的弹出操作服从先入后出的准则,根节点访问结束后需要先访问的是左子节点,所以左子节点在右子节点之后压栈;步骤3是遍历过程终止的条件。
根据上述迭代步骤,图中二叉树的遍历步骤可以分解为如下步骤,对应如图所示。
1. 将n14压栈;
2. 弹出栈顶节点,此时为n14,访问节点n14;
3. 将n14的右子节点n13和左子节点n8依次压入栈中;
4. 弹出栈顶节点,此时为n8,访问节点n8;
5. 将n8的右子节点n7和左子节点n4依次压入栈中;
6. 弹出栈顶节点,此时为n4,访问节点n4;
7. 将n4的右子节点n3和左子节点n2依次压入栈中;
8. 弹出栈顶节点,此时为n2,访问节点n2;
9. n2的右子节点为空,则将n2的左子节点n1压入栈中;
10.弹出栈顶节点,此时为n1,访问节点n1;
11.n1的左子节点为空,则将n1的右子节点n0压入栈中;
12.弹出栈顶节点,此时为n0,访问节点n0;
13.n0为叶节点,则无子节点压栈;
14.弹出栈顶节点,此时为n3,访问节点n3;
15.n3为叶节点,则无子节点压栈;
16.弹出栈顶节点,此时为n7,访问节点n7;
17.将n7的右子节点n6和左子节点n5依次压栈;
18.弹出栈顶节点,此时为n5,访问节点n5;
19.n5为叶节点,无子节点压栈;
20.弹出栈顶节点,此时为n6,访问节点n6;
21.n6为叶节点,无子节点压栈;
22.弹出栈顶节点,此时为n13,访问节点n13;
23.将n13的右子节点n11和左子节点n12依次压栈;
24.弹出栈顶节点,此时为n12,访问节点n12;
25.n12为叶节点,无子节点压栈;
26.弹出栈顶节点,此时为n11,访问节点n11;
27.将n11的右子节点n10和左子节点n9依次压入栈中;
28.弹出栈顶节点,此时为n9,访问节点n9;
29.n9为叶节点,则无子节点压栈;
30.弹出栈顶节点,此时为n10,访问节点n10;
31.n10为叶节点,则无子节点压栈;
32.栈空,遍历过程结束。
图 二叉树前序遍历算法栈结构动态过程
迭代过称中利用了栈结构,图示的栈结构中栈的大小是固定的,事实上在实现时预先设定好栈的大小并不容易,所以在具体实现时,采用第XX章中讨论的链式栈,动态调整栈的大小。
中序遍历
第二种遍历算法称为中序遍历算法。与前序遍历算法相比,中序遍历算法首先访问节点的左子树,然后访问节点自身,最后访问节点的右子树。可见,节点自身是在访问左右子树中间访问的,顾称之为中序。图中的二叉树的中序遍历结果为:
10→-1→6→4→7→2→8→5→9→1→14→3→12→11→13
这一过程用递归的定义方法可以描述为:对根节点rt,如果rt为空,则返回,否则递归遍历rt的左子树,然后访问rt自身,最后遍历rt的右子树,步骤如下:
在执行时,首先中序遍历n14的左子树,即以n8为根节点的子树,此时继续递归遍历n8的左子树,直到节点n1,此时n1没有左子树,故访问节点n1,然后遍历n1的右子树,该右子树为单节点n0,则直接访问节点n0。到此时为止,以n1为根节点的子树遍历结束,即根节点为n2的左子树遍历结束,则访问节点n2。由于n2的右子树为空,则n4的左子树遍历结束,则访问节点n4,然后遍历n4的右子树,即以n3为根节点的子树,该子树为单节点,故直接访问节点n3便可结束右子树的遍历。此时,n8节点的左子树遍历完成,则访问节点n8。同样方法可以分析节点n8的右子树的遍历过程,该子树中节点的遍历次序为:n5,n7,n6。以n8为根节点的子树遍历完成后,首先访问节点n8,然后便递归调用中序遍历算法访问节点n14的右子树,即以n13为根节点的子树,利用相同的分析过程可知,该子树的遍历顺序为:n12,n13,n9,n11,n10。
采用迭代算法实现中序遍历时,其过程与前序遍历的迭代过程有所不同。在前序遍历的算法中,节点访问完成后继续访问该节点子树时,首先访问的是子树的根节点,所以在迭代算法中,我们每次访问一个节点后,只需要将该节点的两个子节点压栈,然后迭代地弹出栈顶节点并访问该节点。但是在中序遍历过程中,首先访问的是节点的左子树,依次递归下去,直至某节点的左子树为空,如图,直至节点n1。在这一过程中需要记录每个左子树的根节点,即将n14,n8,n4,n2,n1压栈。然后依次弹出节点,并访问它,然后要遍历该节点的右子树,同样需要迭代地将该节点的右子树的左子节点压栈,如图。这一过程的步骤如下:
如果根节点rt为空,则返回;否则,首先将根节点rt压栈,并迭代地将根节点的左子节点、左子节点的左子节点压栈,然后迭代执行以下步骤:
1. 弹出栈顶节点n,访问该节点;
2. 如果n的右子节点不为空,将右子节点rgt压栈,并迭代地将rgt的左子节点、左子节点的左子节点压栈;
3. 如果栈不为空,则返回步骤1继续执行,否则结束迭代。
其中步骤1是节点访问操作;步骤2中首先判断栈顶弹出节点的右子节点是否是空节点,如果非空则将该右子节点压栈,然后迭代地将其左子节点压栈;步骤3是遍历过程终止的条件。
图 中序迭代过程
首先将根节点压栈,并迭代地将左子节点n8,n4,n2,n1压栈;在访问节点n8之后将其右子树的根节点n7压栈,并迭代地将其左子节点压栈。
根据上述的迭代步骤,图中二叉树的迭代过程为:
1. 将节点n14,n8,n4,n2,n1依次压入栈中;
2. 弹出栈顶节点,此时为n1,访问n1;
3. 将n1的右节点n0压栈,由于n0的左子节点为空,则无迭代压栈过程;
4. 弹出栈顶节点,此时为n0,访问n0;
5. n0为叶节点,无节点压栈;
6. 弹出栈顶节点,此时为n2,访问n2;
7. n2的右节点为空,无节点压栈;
8. 弹出栈顶节点,此时为n4,访问n4;
9. 将n4的右节点n3压栈,由于n3的左子节点为空,则无迭代压栈过程;
10.弹出栈顶节点,此时为n3,访问n3;
11.n3为叶节点,无节点压栈;
12.弹出栈顶节点,此时为n8,访问n8;
13.将n8的右子节点n7压栈,并将节点n5压栈;
14.弹出栈顶节点,此时为n5,访问n5;
15.n5为叶节点,无节点压栈;
16.弹出栈顶节点,此时为n7,访问n7;
17.将n7的右节点n6压栈,由于n6的左子节点为空,则无迭代压栈过程;
18.弹出栈顶节点,此时为n6,访问n6;
19.n6为叶节点,无节点压栈;
20.弹出栈顶节点,此时为n14,访问节点n14;
21.将n14的右节点n13压栈,并将节点n12压栈;
22.弹出栈顶节点,此时为n12,访问n12;
23.n12为叶节点,无节点压栈;
24.弹出栈顶节点,此时为n13,访问节点n13;
25.将n13的右节点n11压栈,并将n9压栈;
26.弹出栈顶节点,此时为n9,访问n9;
27.n9为叶节点,故无节点压栈;
28.弹出栈顶节点,此时为n11,访问n11;
29.将n11的右节点n10压栈,n10为叶节点,无迭代压栈过程;
30.弹出栈顶节点,此时为n10,访问节点n10;
31.n10为叶节点,无节点压栈;
32.栈为空,迭代过程结束。
图 二叉树中序遍历迭代算法栈结构动态过程
后序遍历
后序遍历与前序遍历的顺序相反,在前序遍历过程中,首先访问的父节点,而在后序遍历中父节点是最后被访问的。后序遍历的递归算法与前序和中序遍历的递归算法非常类似,这里就不在详细讨论,主要讨论后序遍历的迭代算法。
在后序遍历过程中首先遍历的是节点的左子树,这跟中序遍历的第一步相同。所以迭代实现时栈的初始化过程也是将树的根节点压栈,并迭代地将根节点的左子节点、左子节点的左子节点压栈。不同的是,接下来不是弹出栈顶节点并访问它,因为对该节点的访问应该在遍历了节点的右子树之后进行。所以正确的操作应该首先读取栈顶节点(不是弹出栈顶节点)。接下来跟中序遍历的迭代算法类似,如果右子节点不为空,则首先将该节点的右节点rgt压栈,然后迭代地将rgt的左子节点压栈;如果该节点的右子节点为空,则弹出栈顶节点并访问它。但这一过程存在一个很大问题,我们举例来讨论这一过程。如图为图中的一个子树。
子树 (a) (b) (c)
图 二叉树后序遍历迭代算法分析
如图,栈中元素为节点n2和n1,其中节点n1位于栈顶,如图(a)。根据上面我们讨论的步骤,首先读取n1,并将其右子节点n0压栈,由于n0为叶节点,所以没有迭代压栈的操作,此时栈顶节点为n0,如图(b)。接下来再次读取栈顶节点n0,由于n0没有有节点,则弹出栈顶节点n0,如图(c),并访问该节点。此时栈中节点与(a)中相同,迭代过程依旧按照上述步骤进行,将陷入死循环中。
在这里节点的左子树遍历结束后,父节点需要继续保留在栈中,并等待右子树遍历结束后才能从栈中弹出;右子树遍历结束后,父节点需要从栈中弹出,但是问题在于,如何区分是左子树遍历结束还是右子树遍历结束,并在右子树遍历结束时将父节点从栈中弹出?
通过仔细观察后序遍历过程,我们可以发现,对父节点而言,在左子树遍历结束时,最后一个访问的节点是父节点的左子节点,同样地,在右子树遍历结束时,最有一个访问的节点是父节点的右子节点。根据这个规律,我们可以设计一个节点指针last_visited,指向最近一个访问的节点,并判断它是否与父节点的右子节点相同。如果相同则说明此时节点的右子树遍历结束,接下来应该弹出栈顶节点并访问它。我们可以对图中的例子重新讨论:
图(a)中,此时还没有节点被访问,即last_visited为空,所以last_visited与n1的右子节点不相同,则直接读取栈顶节点;图(c)中,在弹出并访问节点n0时,last_visited ← n0,此时last_visited与n1的右子节点相等,表示n1的右子树访问结束,所以接下来应弹出并访问栈顶节点n1。
根据上面的讨论,二叉树后续遍历算法的迭代实现步骤如下:
如果根节点rt为空,则返回;否则,首先将根节点rt压栈,并迭代地将根节点的左子节点、左子节点的左子节点压栈,然后迭代执行以下步骤:
1.读取栈顶节点n,如果该节点的右子节点不为空并且右子节点与最近被访问的节点last_visited不相同,则将节点的右子节点压栈,并迭代地将右子节点的左子节点压入栈中;
2.如果该节点n的右子节点为空或者右子节点与最近被访问的节点last_visited相同,则弹出栈顶节点n,并访问该节点,同时将最近访问节点last_visited更新为节点n;
3.如果栈不为空,返回步骤1并继续,否则迭代结束;
上述步骤1中读取栈顶节点,不直接访问该节点,而是首先判断该节点的右子树是否已经遍历过,如果没有,则将节点的右子节点及其左子节点迭代地压入栈中;如果该节点的右子树已经遍历过,则直接弹出栈顶节点,并访问它;步骤三为迭代过程结束的条件。
为了详细说明上述步骤,我们仍然以图中的二叉树为例,对每个步骤进行进一步说明:
1. 将节点n14,n8,n4,n2,n1依次压入栈中,last_visited = NULL;
2. 读取栈顶节点,n1,该节点的右节点不为空,且与last_visited不相同,则将n1的右子节点n0压栈,n0为叶节点,无迭代压栈过程;
3. 读取栈顶节点,n0,该节点的右节点为空,则弹出栈顶节点n0,访问节点n0,并且last_visited ← n0;
4. 读取栈顶节点,n1,此时n1的右子节点与last_visited相同,则弹出栈顶节点n1,访问节点n1,并且last_visited ← n1;
5. 读取栈顶节点,n2,此时n2的右子节点为空,则弹出栈顶节点n2,访问节点n2,并且last_visited ← n2;
6. 读取栈顶节点,n4,此时n4的右子节点不为空,并且与last_visited不相同,则将n4的右子节点n3压栈,n3为叶节点,无迭代压栈过程;
7. 读取栈顶节点,n3,此时n3的右子节点为空,则弹出栈顶节点n3,访问节点n3,并且last_visited ← n3;
8. 读取栈顶节点,n4,此时n4的右子节点与last_visited相同,则弹出栈顶节点n4,访问节点n4,并且last_visited ← n4;
9. 读取栈顶节点,n8,此时n8的右子节点不为空,并且与last_visited不相同,则将n8的右子节点n7压栈,并迭代地将n7的左子节点n5压入栈中;
10. 读取栈顶节点,n5,此时n5的右子节点为空,则弹出栈顶节点n5,访问节点n5,并且last_visited ← n5;
11. 读取栈顶节点,n7,此时n7的右子节点不为空,并且与last_visited不相同,则将n7的右子节点n6压栈,n6为叶节点,无迭代压栈过程;
12. 读取栈顶节点,n6,此时n6的右子节点为空,则弹出栈顶节点n6,访问节点n6,并且last_visited ← n6;
13. 读取栈顶节点,n7,此时n7的右子节点与last_visited相同,则弹出栈顶节点n7,访问节点n7,并且last_visited ← n7;
14. 读取栈顶节点,n8,此时n8的右子节点与last_visited相同,则弹出栈顶节点n8,访问节点n8,并且last_visited ← n8;
15. 读取栈顶节点,n14,此时n14的右子节点不为空,并且与last_visited不相同,则将n14的右子节点n13压栈,并迭代地将 n13的左子节点n12压栈;
16. 读取栈顶节点,n12,此时n12的右子节点为空,则弹出栈顶节点n12,访问节点n12,并且last_visited ← n12;
17. 读取栈顶节点,n13,此时n13的右子节点不为空,并且与last_visited不相同,则将n13的右子节点n11压栈,并迭代地将 n11的左子节点n9压栈;
18. 读取栈顶节点,n9,此时n9的右子节点为空,则弹出栈顶节点n9,访问节点n9,并且last_visited ← n9;
19. 读取栈顶节点,n11,此时n11的右子节点不为空,并且与last_visited不相同,则将n11的右子节点n10压栈,n10为叶节点,无迭代压栈过程;
20. 读取栈顶节点,n13,此时n13的右子节点与last_visited相同,则弹出栈顶节点n13,访问节点n13,并且last_visited ← n13;
21. 读取栈顶节点,n14,此时n14的右子节点与last_visited相同,则弹出栈顶节点n14,访问节点n14,并且last_visited ← n14;
22. 此时栈中无节点,结束迭代过程。
图 后序遍历迭代算法实现栈动态变化过程
从迭代过程可以发现,与前序遍历和中序遍历的迭代算法不同的是,在前序遍历和中序遍历过程,每次迭代都是将栈顶节点弹出,然后访问该节点,并对栈做相应的调整;但是在后序遍历的迭代算法中,每次迭代不需要判断栈顶节点的属性,有些情况下栈顶节点可以直接弹出,而其它情况下栈顶节点不可以直接弹出,并且需要向栈中压入新的节点。所以直观上我们也能发现,后序遍历的迭代算法对栈的操作次数比前序和中序遍历都要多一些。
层序遍历
最后一种常见的遍历算法称为层序遍历算法,这种算法比前三种算法更直观,其遍历按照节点出现的层数由小到大的顺序进行,在每一层节点又按照由左向右的次序访问。例如图中的二叉树,其层序遍历次序为:
第0层:n14;
第1层:n8,n13;
第二层:n4,n7,n12,n11;
…… ……
层序遍历过程不太适合用递归算法来完成,我们具体来分析以下层序遍历的规律,并希望能找到解决层序遍历的合适算法。
第0层:根节点n14;
第1层:n8和n13都是n14的子节点;
第2层:节点n4和n7都是n8的子节点,n12和n11都是节点n13的子节点;
第3层:节点n2和n3是节点n4的子节点,n5和n6是节点n7的子节点,n9和n10是节点n11的子节点,第2层中n12在第3层没有子节点;
第4层:节点n1是n2的子节点;
第5层:节点n0是n1的子节点;
可以发现,在第0层访问了节点n14之后,下一次必须访问n14的两个子节点;第1层访问了n8和n13,在第2层必须先访问n8的两个子节点,然后访问n13的子节点……。
如图,对这一过程,分析每个节点的访问过程,设计一个线性表,首先保存n14,如图(a);然后将其从表头取出,并将它的两个子节点添加到表尾部,如图(b);接着迭代这个过程,每次将表头节点取出并访问该节点,同时,如果该节点有子节点,则将该节点的子节点添加到表尾部,如果该节点没有子节点,则不添加节点。
进一步分析上面所提及的线性表,向表中添加节点时节点添加到线性表的表尾,每次从表中取走节点时,都是从表头取节点。这一操作规则即为先入先出规则,第XXX章中讨论的队列满足这一规则。根据上面的分析,层序遍历可以借助队列来实现,其步骤:
如果根节点rt为空,则返回,否则,创建队列queue,并添加二叉树根节点,迭代执行以下步骤:
1. 取出队列首部节点n,访问该节点;
2. 向如果节点n的左子节点不为空,则向queue中添加左子节点;如果节点n的右子节点不为空,则像queue中添加右子节点;
3. 如果队列不为空,返回步骤1继续迭代,否则迭代结束。
图 二叉树层序遍历过程图示
四种遍历算法的实现
根据以上对四种遍历算法讨论,我们可以基于二叉树节点类设计二叉树数据结构如下:
代码:
在前面章节中我们讨论最大堆时提到,堆结构是一种特殊二叉树,即完全二叉树。给定一组具有可比较性的节点(继承了Comparable类),就可以将它们创建成一个最大堆。但是在这里设计的二叉树类中,只有根节点一个成员变量,节点之间没有特定的关系,这是一个广义的二叉树。接下来通过示例程序来验证该二叉树的各种遍历算法。
在示例程序中,首先创建一棵二叉树,其结构和节点如图,对分别用递归和迭代算法进行前序遍历、中序遍历和后序遍历,并用迭代法实现层序遍历,其结果如下:
递归算法实现前序遍历:
1→2→4→6→10→-1→7→5→8→9→3→14→11→12→13→
迭代算法实现前序遍历:
1→2→4→6→10→-1→7→5→8→9→3→14→11→12→13→
递归算法实现中序遍历:
10→-1→6→4→7→2→8→5→9→1→14→3→12→11→13→
迭代算法实现中序遍历:
10→-1→6→4→7→2→8→5→9→1→14→3→12→11→13→
递归算法实现后序遍历:
-1→10→6→7→4→8→9→5→2→14→12→13→11→3→1→
迭代算法实现后序遍历:
-1→10→6→7→4→8→9→5→2→14→12→13→11→3→1→
迭代算法实现层序遍历:
1→2→3→4→5→14→11→6→7→8→9→12→13→10→-1→
小结
前四节中我们主要讨论了二叉树的性质,这些性质在广义树结构中基本无法获得,这得益于二叉树的受限的结构性。此外还着重讨论了二叉树的四种遍历算法,其中分别讨论了前序、中序和后序遍历的迭代和非迭代实现,并讨论了层序遍历算法的实现方法。这些遍历算法同样适用于最大堆和在后面章节中将讨论的二叉搜索树等节点间具有特定关系的结构。
Huffman编码树
Huffman编码是一种可变长编码方式,是由美国数学家David Huffman创立的,是二叉树的一种特殊转化形式。编码的思想是:将使用次数多的代码转换成长度较短的码字(一般是0-1比特型的码字),而使用次数少的可以使用较长的码字,并且保持编码的唯一可解性。
Huffman编码已应用于多个重要的数据压缩领域,而数据压缩技术的理论基础是信息论。根据信息论的原理,可以找到最佳数据压缩编码方法,数据压缩的理论极限是信息熵。如果要求在编码过程中不丢失信息量,即要求保存信息熵,这种信息保持编码又叫做熵保存编码,或者叫熵编码。从信息论的角度来看,Huffman编码是一种熵编码。
Huffman算法的最根本的原则是:Huffman树的带权路径长最小。所谓树的带权路径长度记为WPL = W1*L1+W2*L2+W3*L3+...+Wn*Ln, n表示Huffman二叉树的叶节点的个数。Wi(i=1,2,...,n)表示n个权值,Li(i=1,2,...,n)表示n个叶节点的层数,或称为路径长度。
对n个信源信息符号进行Huffman编码的具体步骤归纳如下:
1.概率统计,得到n个符号的不同概率;
2. 将n个信源信息符号的n个概率,按概率从小到大排序;
3. 将n个概率中,最后两个小概率相加,这时概率值个数减为n-1个;
4. 将n-1个概率,按大小重新排序;
5. 重复3将新排序后的最后两个小概率再相加,相加和与其余概率再排序;
6. 如此反复重复n-2次,得到只剩两个概率序列;
7. 以二进制码元(0-1)赋值,构成哈夫曼码字,编码结束。
下面举例来进一步解释Huffman编码步骤1~6的过程,信源信息符a1,a2,a3,a4,a5,a6,a7,其统计概率为:
P(a1)=0.2,P(a2)=0.19,P(a3)=0.18,
P(a4)=0.17,P(a5)=0.15,P(a6)=0.1,P(a7)=0.01。
对信源信息符进行Huffman编码过程:
1.按照信息符的概率值,进行由小到大排序,排序后结果为:
2.将a7和a6组合成一个中间节点,用两者概率值之和标注该中间节点,并用该中间节点代替a7和a6,重新排序后结果为:
可以发现,中间节点的概率值虽然a7和a6两者之和,但是其值依然是最小的,不需要进行顺序的调整。
3.继续将节点0.11和a5组合成中间节点,用0.11和a5的概率值之和标注该节点,重新排序后结果为:
此时中间节点的概率值大于其它任一节点的概率值,需要进行顺序调整。
4.将a4和a3组合成中间节点,调整顺序,结果为:
5.将a2和a1组合成中间节点,调整循序,结果为:
6.将中间节点0.26和0.35组合,调整顺序后结果为:
7.最终将节点0.39和0.61组合,得到Huffman树:
Huffman树结构的实现
接下来我们首先讨论Huffman树的实现,然后讨论Huffman树用于Huffman编码的用法和性质等。
Huffman树是一种特殊的二叉树,本章前部分讨论了二叉树数据结构,这里我们首先分析Huffman树节点内容的数据结构。
从上面的示例中可以发现在建立的Huffman树的叶节点包含信息符和概率值,中间节点没有信息符,只有概率值,且其值为两个子节点的概率值之和。可以对叶节点和中间节点分别设计数据结构,更为有效的方法是设计统一的数据结构,并对定义中间节点的信息符为无效信息符“□”。
此外在构造Huffman树的过程中需要对各节点的顺序进行调整,这要求节点之间具有可比较性,比较的对象是节点的概率值,可以通过继承Comparable接口来实现。
根据上面的讨论设计节点内容类设计为:
Huffman树的节点内容类即HuffmanPair类包含两个私有成员变量:字符串类型变量c表示信息符,双精度变量freq表示信息符的频率。无参数的构造函数将c赋值为无效信息符“□”。最后的函数compareTo实现Comparable的结构函数,两个HuffmanPair类的对象之间进行比较,比较的是它们的频率值大小,如果当前对象的频率值较大,则返回1,如果较小则返回-1,相等返回0。为了便于节点内容显示,类中包含了toString函数,同时返回信息符和信息符的频率值。
Huffman树HuffmanTree的节点采用前几节中设计的二叉树节点类LinkBinTreeNode的对象,在使用时节点元素项为HuffmanPair类的对象。其层次关系为:
Huffman树的构造从LinkBinTreeNode类型的数组开始,每个LinkBinTreeNode类型的数组元素都包含的节点内容都是HuffmanPair类型的。设该数组为hufflist,其长度为n。
定义一个数组下标validpos,初始化指向数组hufflist的第一个位置,即validpos ← 0。在构造Huffman树的过程中,每次向Huffman树中添加一个节点,数组就减少一个节点,validpos用于指向每一次用于建树的节点。validpos初始化从0位置开始,第一次将数组的前两个节点组合后将得到的中间节点保存至位置1,即位置validpos+1,此时位置0处的节点变成无效节点。紧接着将validpos增加1,指向刚创建的中间节点。
由于该节点的概率值是原先两个节点的和,其概率值将可能比后面的节点的概率值大,该中间节点的位置可能需要做调整:从validpos+1位置开始寻找第一个比该概率值大的节点,然后进行元素位置的调整。不断地迭代这一过程直至validpos指向数组最后一个元素。
具体步骤如下:
1.validpos ← 0,将数组hufflist进行由小到大排序。
2.并将hufflist的validpos位置和validpos + 1位置上的两个节点,用lft和rght表示,进行合并:
首先,创建新的HuffmanPair类型的对象r,将位置为lft设置为r的左子节点内容,rght设置为r的右子节点内容;
然后,r的频率freq设置为lft和rght的freq的和,其信号符c设置为无效信号符“□”
最后,validpos ← validpos + 1,并将hufflist的validpos位置处元素重设为r;
3.调整hufflist的validpos处的元素的位置:
在hufflist中寻找第一个元素不小于hufflist[validpos]的位置idx;
将hufflist中validpos~idx-2位置上的元素整体右移一位,然后将数组原先idx-1位置上的元素赋值到validpos位置;
4.返回2继续迭代,直至validpos等于n-1为止。
以hufflist为如下情况时为例:
1.将hufflist中元素按由小到大排序,设 validpos ← 0:
其中阴影位置为validpos;
2.将validpos=0位置和validpos+1=1位置上的两个节点合并,得到的中间节点,概率值为0.01+0.1=0.11,重新调整位置后,结果为:
validpos ← validpos+1=1,事实上此时位置0上的元素已经无效了,用浅色表示;
3.将validpos=1位置和validpos+1=2位置上的两个节点合并,得到的中间节点,概率值为0.11+0.15=0.26,重新调整位置后,结果为:
validpos ← validpos+1=2,可以发现,这一步中得到的概率值为0.26的中间节点的位置是经过调整的,从位置2调整至位置位置6;
4.将validpos=2位置和validpos+1=3位置上的两个节点合并,得到的中间节点,概率值为0.17+0.18=0.35,重新调整位置后,结果为:
validpos ← validpos+1=3,可以发现,这一步中得到的概率值为0.35的中间节点的位置是经过调整的,从位置3调整至位置位置6;
5.将validpos=3位置和validpos+1=4位置上的两个节点合并,得到的中间节点,概率值为0.19+0.2=0.39,重新调整位置后,结果为:
validpos ← validpos+1=4,可以发现,这一步中得到的概率值为0.39的中间节点的位置是经过调整的,从位置4调整至位置位置6;
6.将validpos=4位置和validpos+1=5位置上的两个节点合并,得到的中间节点,概率值为0.26+0.35=0.61,重新调整位置后,结果为:
validpos ← validpos+1=5,可以发现,这一步中得到的概率值为0.61的中间节点的位置是经过调整的,从位置5调整至位置位置6;
7.最后一次节点合并,最终得到hufflist为:
此时validpos等于n-1,结束迭代。
将hufflist数组最后一个元素,即hufflist[n-1]赋值给二叉树的根节点即完成Huffman树的构造过程。
在实现的Huffman树数据结构中,私有成员变量为二叉树节点类型的Huffman树根节点root。其中函数buildTree实现Huffman树的创建,函数参数为LinkBinTreeNode类型的数组。建树时首先将数组中所有节点按照信号符的频率值由小到大进行排序,这里使用简单的选择排序。然后迭代地进行节点组合和位置调整。
此外这里我们还采用多种方式来显示Huffman树中每个节点的信息。在前一章内容中我们讨论过广义树90度旋转后的显示方法,我们也讨论过堆结构90度旋转后的显示方式,这里我们也对Huffman树采用90度旋转后的显示方式。
函数ShowHT在高度为h处显示节点r的信息,该函数是一个递归函数。如果节点r为空则返回;否则,如果r的右子节点不为空,则首先递归地在高度为h+1处打印r的右子节点;然后在高度h处打印节点r,此时调用函数printnode;最后,如果r的左子节点不为空,则递归地在高度为h+1处打印r的左子节点。
前面我们也提到,Huffman树是一种特殊的二叉树,所以二叉树的所有遍历算法对Huffman树同样适用。这里我们对Huffman树也进行前序、中序、后序以及层序遍历,直接调用二叉树类的对应函数即可。
下面是Huffman树的测试示例程序:
其中创建的Huffman树的各节点信号符合各自频率大小与上面例程中相同:
P(a1)=0.2,P(a2)=0.19,P(a3)=0.18,
P(a4)=0.17,P(a5)=0.15,P(a6)=0.1,P(a7)=0.01。
分别对Huffman建树、90度旋转打印、各种遍历算法进行测试,结果如下:
递归算法实现前序遍历:
--□ 1.0→--□ 0.39→--a2 0.19→--a1 0.2→--□ 0.61→--□ 0.26→--□ 0.11→--a7 0.01→--a6 0.1→--a5 0.15→--□ 0.35→--a4 0.17→--a3 0.18→
迭代算法实现前序遍历:
--□ 1.0→--□ 0.39→--a2 0.19→--a1 0.2→--□ 0.61→--□ 0.26→--□ 0.11→--a7 0.01→--a6 0.1→--a5 0.15→--□ 0.35→--a4 0.17→--a3 0.18→
递归算法实现中序遍历:
--a2 0.19→--□ 0.39→--a1 0.2→--□ 1.0→--a7 0.01→--□ 0.11→--a6 0.1→--□ 0.26→--a5 0.15→--□ 0.61→--a4 0.17→--□ 0.35→--a3 0.18→
迭代算法实现中序遍历:
--a2 0.19→--□ 0.39→--a1 0.2→--□ 1.0→--a7 0.01→--□ 0.11→--a6 0.1→--□ 0.26→--a5 0.15→--□ 0.61→--a4 0.17→--□ 0.35→--a3 0.18→
递归算法实现后序遍历:
--a2 0.19→--a1 0.2→--□ 0.39→--a7 0.01→--a6 0.1→--□ 0.11→--a5 0.15→--□ 0.26→--a4 0.17→--a3 0.18→--□ 0.35→--□ 0.61→--□ 1.0→
迭代算法实现后序遍历:
--a2 0.19→--a1 0.2→--□ 0.39→--a7 0.01→--a6 0.1→--□ 0.11→--a5 0.15→--□ 0.26→--a4 0.17→--a3 0.18→--□ 0.35→--□ 0.61→--□ 1.0→
迭代算法实现层序遍历:
--□ 1.0→--□ 0.39→--□ 0.61→--a2 0.19→--a1 0.2→--□ 0.26→--□ 0.35→--□ 0.11→--a5 0.15→--a4 0.17→--a3 0.18→--a7 0.01→--a6 0.1→
Huffman树的性质
Huffman数的建立方法是贪心算法(greedy algorithm)的一个例子,关于贪心算法的详细介绍见网上相关搜索结果。通过上面的详细讨论我们可以发现,每一步节点组合时,两个节点是概率值最小的两个。但是这样的贪心过程,能否保证得到所要的结果,即带权路径长最小?下面我们来证明Huffman树的确给出了信息符的最佳排列。
引理1 一棵至少包含两个节点的Huffman树,会把信号符频率最小的两个信号符作为兄弟节点存储,其层数不比树中其它任何叶节点小。
证明:引理表述的意义是,频率越小的信号符将处在层数越大的叶节点位置,这以规律直观上也比较容易发现。
采用反证法证明:
记频率值最小的两个信息符分别为x1和x2。由于在构造Huffman树时第一步选择的两个节点就是它们,所以它们一定是一对兄弟节点。假设x1和x2并不是Huffman树中层数最大的节点,即存在x0,其层数比x1,x2大,如图所示。此时x1和x2的父节点F的概率值一定比x0大,否则在构造树时将回选F而不是x0作为F2的子节点。但是,在“x1和x2是概率值最小的两个信息符”这一前提下,这种情况不可能发生。
图 存在矛盾的Huffman树,其中三角形表示的子树
性质1 对于给定的一组信息符,Huffman树实现了“带权路径最小”。
证明:对信息符数n进行归纳证明。
当n=2时,Huffman树一定有最小带权路径,因为此时只可能有两种树,并且两个叶节点的带权路径相同;
假设n<k个叶节点的Huffman树具有最小带权路径;
n=k时,设w1 < … < wk,这里w1,…,wk表示信息符的权。记F是w1和w2的两个信息符的父节点。根据引理,它们已经是树T中层数最大的节点,不存在层数更大、权值更大的节点能替换它们从而减小路径长度。记Huffman树T’与T完全相同,除了把节点F(连同其子节点形成的子树)换为一个叶节点F’,其权重等于w1+w2。根据归纳假设,T’具有最小带权路径。最后再把子节点w1和w2替换F’,则与T’等价的T也应有最小带权路径。
Huffman编码及其用法
在本节一开始介绍Huffman编码时其最后一步是:以二进制码元(0-1)赋值,构成Huffman码字,这个0-1形式的Huffman码字其实就是Huffman编码的最终表现形式。
一旦Huffman树构造完成,我们就可以对每个信息符进行编码。从根节点开始,分别用0和1标记二叉树的每一条边。“0”对应于连接左子节点的边,“1”对饮连接右子节点的边。如图表示了这一过程。信息符的Huffman编码就是由从根节点到该信息符叶节点的路径的上每条边的标记组成的。图中各个信息符的Huffman编码结果如下:
信息符 |
频率 |
编码 |
码长 |
a1 |
0.2 |
01 |
2 |
a2 |
0.19 |
00 |
2 |
a3 |
0.18 |
111 |
3 |
a4 |
0.17 |
110 |
3 |
a5 |
0.15 |
101 |
3 |
a6 |
0.1 |
1001 |
4 |
a7 |
0.01 |
1000 |
4 |
图 对应图的Huffman编码结果
给定一个信息符串,根据编码结果可以对其进行Huffman编码,从而得到一个0-1比特串。只要将信息符串中每个信息符例替换为对应的Huffman码字即可。如给定“a1a1a4a1a2a5”这个信息符串,可以用“01011100100101”表示。
对编码结果进行反编码(译码)时,从左向右逐位判别,这可以对Huffman树用其码字生成过程的逆过程实现。从树的根节点开始信息符串的码字进行反编码。根据每一位值是0或者1确定Huffman树在每个一个分支处选择左节点还是有节点——直到达到一个叶节点为止,这个叶节点包含的信息符就是译码得到的结果。按照这种方法可以对给定的信息符的编码码字进行译码。
对码字“01011100100101”进行译码,从根节点开始,由于第一位为0,所以选择左分支。下一位是1,所以选择右分支,到达叶节点,对应的信息符为a1,即译出的结果就是a1。接着从新回到根节点,从根节点出发,从码字的第3个比特开始,它是0,选择左分子,接着选择右分支,依然译出a1。接着从根节点出发,比特位为1,选择右分支,然后依次选择右分支和左分支到达叶节点,译出结果为a4。类似地,完成全部译码可以发现最后得到的结果是“a1a1a4a1a2a5”。
事实上,Huffman编码是一种前缀码。如果一个码字中的任何一个码字都不是另一个码字的前缀,称这组码字符合前缀性。这种前缀性保证了信息符串在译码是不会有多种可能。也就是说,在译码时,一旦到达某个代码的最后一位,我们就能判断它所代表的信息符。由于任何码字的前缀对应一个分支节点,而每个码字有对应这信息符,所以Huffman编码得到的码字是符合前缀性的。