数据结构中,树只是一种逻辑结构,在最底层并没有这种存储结构的存在,只是我们因为需要一种比较好的结构来描述我们的问题,所以树结构就应运而生。
在树的结构中,二叉树是一种较为常用的树结构,并且还很重要,几乎我们平常编程时所说的树,都是指的二叉树。
其实家谱就是一种树结构,他清晰的向我们传达了家人之间的信息。
1. 二叉树的存储
二叉树的存储也可以像矩阵一般,存储在一维数组中,或者是通过链表进行存储。存储于数组中,需要相应的映射关系,并且还存在很多问题,如果存储的是完全二叉树,那么从树结构到数组的存储映射关系就非常简单了,而且浪费空间会少很多(原因后文会进行解释)。
以链表形式存储二叉树,可以说是普遍的实现方式了。
1.1 顺序存储
顺序存储就是将一个二叉树存储在一个一维数组中,这种形式非常简单,而且是最节省空间的存储方式,多数完全二叉树的存储会采用这种形式实现。
这个原理非常简单,就是对二叉树上的结点按顺序(从上到下,自左向右)进行编号,然后按照编号的顺序将其按顺序存储在一维数组中,其映射关系为:
A[0]存储二叉树的根结点,A[i]存储二叉树中编号为i的结点,编号为i的结点,其左孩子如果存在,则存储在A[2i+1]处,其右孩子如果存在,则存储在A[2i+2]处。
如果要存储一个非完全二叉树,顺序存储也是可以的,但是却并不像完全二叉树存储时那么方便。
可以看到,在非完全二叉树的顺序存储中,我们要先将非完全二叉树补成完全二叉树(方块仅为了方便对其结点进行编号所假设补全的结点,实际并不存在),然后对二叉树的结点进行编号,然后存储到数组中,但是我们可以看到,数组中可能会空出多个位置,增大存储开销,带来不必要的损失。
1.2 二叉树的链接存储
考虑到数组对于非完全二叉树的存储不是那么友好,所以我们通常会选用链接存储结构来进行二叉树的存储。
与矩阵类似,我们需要针对某个实际问题设计对应的存储结点。
图中∧符号表示空指针,结点中Left表示左孩子指针,Right表示右孩子指针,Data表示数据域,数据域可根据具体问题而不同。
struct Node
{
int data;
Node *left, *right;
Node(int data = 0, Node *left = null, *right = null);
};
1.2.1 线索二叉树
二叉树的链接存储模式下,衍生出了不少的二叉树种类,线索二叉树就是其中的一种。
线索二叉树的树结点在原有结点内容下,增加了前驱和后继结点的指针。
这里只给出中序线索二叉树的链接表示,先序与后序暂不给出(什么是先序,中序,后序在后文会逐渐讲解)。
我们从图中可以看出,就只是五个结点的存储,线索二叉树的空指针就占了很大比重,这表示存储空间的浪费问题并没有得到解决。
为了改进这种情况,有人提出另一种方案:
-
将原指针域Pred和Succ替换成存储一个二进制位的域LThread和RThread
-
若结点t有左孩子,则left指向t的左孩子结点,LThread值为0;若无,则left指向t的前驱结点,LThread值为1,此时我们称该left指针为线索。
-
若结点t有右孩子,则right指向t的右孩子结点,RThread值为0;若无,则right指向t的后继结点,RThread值为1,此时我们称right指针为线索。
根据这种方案进行改进后,不仅释放了指针域Pred和Succ所占的空间,还充分的利用了left和right指针域,减少了空指针的存在。
2. 二叉树的遍历
二叉树有三种遍历的形式,分别为先根序,中根序,后根序(亦称先根遍历,中根遍历,后根遍历),其主要区别是对根的访问时机。
我们以先根序为例,进行访问根,遍历左右子树操作的解析。
这个二叉树遍历的结果为ABDFCE
- 先根遍历二叉树Tree_0
-
访问Tree_0的根结点A
-
先根遍历A的左子树(即以B为根的子树T1)
- 访问T1的根结点B
- 先根遍历B的左子树(左子树为空,返回B结点)
- 先根遍历B的右子树(右子树不为空,则访问以D为根的子树T2)
- 访问T2的根结点D
- 先根遍历D的左子树(不为空,则访问以D为根的子树T3)
- 访问T3的根结点F
- 先根遍历F的左子树(为空,返回F)
- 先根遍历F的右子树(为空,返回F)
- T3已遍历完成,返回T2子树的根结点D
- 先根遍历D的左子树(不为空,则访问以D为根的子树T3)
- T2子树已遍历完成,返回T1子树的根结点B
- 访问T2的根结点D
- T1子树已遍历完成,返回根结点A
-
先根遍历A的右子树(即以C为结点的子树T4)
- 访问T4的跟结点C
- 先根遍历C的左子树(不为空,即访问以E为根结点的子树T5)
- 访问T5的根结点E
- 先根遍历E的左子树(为空,返回E)
- 先根遍历E的右子树(为空,返回E)
- 返回T4的根结点C
- 先根遍历C的右子树(为空,返回结点A)
-
根结点A已遍历完成,算法结束。
中根遍历,后根遍历与先根遍历操作类似,仅对根的访问时机不同。
2.1 二叉树遍历的实现方式
我们现在知道对二叉树的遍历有先根序,中根序以及后根序三种,而在对先根遍历方式的算法描述,我们用到的是递归的思想,实现起来也比较简单,只需不断的调用函数自身,等到满足递归出口条件时,就可以结束算法。
void PreOrder(Node *t)const
{
if (t != NULL) //递归出口
{
cout<<t->GetData()<<endl; //打印根结点值
PreOrder(t->GetLeft()); //先根遍历左子树
PreOrder(t->GetRight()); //先根遍历右子树
}
}
这只是递归的方式遍历,我们还有其他的非递归方式,如堆栈,队列等。
在设计堆栈方式时,我们需要考虑到出栈和弹栈的恢复性,所以在堆栈上,我们为每个结点增加了一项信息——访问计数(设为i)。表示为(结点,i)
这里我们给出一个后根遍历的算法步骤:
-
i=0,将(结点P,1)压栈,若该结点左子树存在,则将(左孩子(P),1)压栈,准备遍历其左子树。
-
i=1,此时宜遍历完结点P的左子树,将(结点P,2)压栈,即将i设置为2,若其右子树不为空,则将(右孩子(P),1)压栈,准备遍历其右子树。
-
若i=2,此时已遍历完成结点P的右子树,访问结点P的数据域。
这段算法也是按顺序重复进行的,直到满足出口条件。
上述的是堆栈法,接下来的层次遍历,我们需要用到队列,这个遍历方式不同于先根,中根,后根遍历的方法。
层次遍历是将树分层,自根结点所在层开始,由0开始编号,逐层增大。
层次遍历的方法为:
-
根结点入队
-
若队列非空,取队首元素访问,若其坐指针域不为空,则将其左孩子入队;若其右指针域非空,则将其右孩子入队;重复本步骤直到队列空。
void LevelOrder(Node *t)const
{
if (t == NULL) return;
Node *p;
AQueue q; //假设实现了一个名为AQueue的队列
if (t != NULL)
q.QInsert(t); //根结点入队
while (!q.IsEmpty())
{
q.QDelete(p); //QDelete中的p用来存放被删除的结点
cout<<p->GetData()<<endl;
if (p->GetLeft()) != NULL)
q.QInsert(p->GetLeft());
if (p->GetRight() != NULL)
q.QInsert(p->GetRight());
}
}
3. 二叉树的实现
关于存储方式和二叉树的遍历方式就已经算是讲完了,接下来要进行二叉树的具体实现。
首先,针对实际的问题,我们需要选择二叉树的存储方式,是顺序存储,还是链接存储(但大多数情况我们都选用链接结构,故而该步骤可以省略)。
其次,针对我们选择的存储方式,我们进行相应的结构设计,假设选择链接存储。
做完这些,我们需要考虑二叉树都需要哪些具体的操作,比如,创建,复制,增加,删除,修改,查找,遍历这些操作,都是二叉树的基本操作。我们必须要实现。
在这里设计一个通用化的二叉树:
class Node
{
private:
char data;
Node *left;
Node *right;
public:
//构造器
//get(),set()等函数
};
class BinTree
{
private:
Node *root; //根结点
int stop; //构造二叉树时的结束符
public:
BinTree(Node *t = NULL):root(t) { };
virtual ~BinTree() { Del(root); }
Node *Father(Node *t, Node *p); //搜索父结点
Node *Find(Node *t, const char &item); //查找二叉树中数据域为item的值
void DelSubTree(Node *t); //删除结点t的子树(左右子树)
//基本操作
void CreateBinTree(int stop = 0); //默认遇到0结束创建
Node *Create();
Node *CopyTree(Node *t);
Node GetRoot() { return root; }
void SetRoot(Node *t) { root = t; }
int GetStop() { return stop; }
void SetStop(int s) { stop = s; }
int IsEmpty() { return root == NULL; }
//遍历方式,均以参数中的结点为根
void PreOrder(Node *t)const; //先序遍历
void InOrder(Node *t)const; //中序遍历
void PostOrder(Node *t)const; //后根遍历
void LevelOrder(Node *t)const; //层次遍历
void NorecPreOrder(Node *t)const; //非递归先根遍历
void NorecInOrder(Node *t)const; //非递归中根遍历
void NorecPostOrder(Node *t)const; //非递归后根遍历
};
4. 哈夫曼树
哈夫曼树的提出是为了解决数据压缩问题的。
为什么要进行数据压缩?要知道计算机诞生的年代,乃至上世纪八十年代,计算机的存储都是以kb为单位的,哪会像现在这样动辄以TB计算,像C语言这种面向底层的高级语言都不是很流行,汇编语言才是最流行的。所以为了避免空间的浪费,我们提出了数据压缩。
其实放到现在也一样,压缩文件可以节省不少的空间。
解决数据压缩问题,一般都是采用不等长的二进制编码,因为这种编码形式,灵活可变,相比如固定长度的编码存储不会浪费太多空间,保证每一个字符都能以最小的二级制数来存储。
我们知道,在一个文件中,如txt文件,其中存储的字符并不是全部都不同的,至少我们可以在平时写的文档中找到一个出现过两次的字符,而且常用的字符占所有字符的少数,你说6763个汉字,常用的也不过一半而已,如果使用定长的编码格式,那么考虑到非常用字,那么一个文件就会出现空间浪费,如果只对常用字进行编码,那么其编码长度就变小了,而且节省了存储空间。
但是为了防止不定长的编码出现相同的情况(这种情况我们称为编码的多义性),我们增加了前缀码来进行区分。
4.1 哈夫曼算法
哈夫曼算法其实就是求一个最优二叉树的算法。
扩展:
最优二叉树是以扩充二叉树为基础的,而扩充二叉树简单来说,就是为一个普通二叉树所有结点(内结点)出现的空子树(如果该结点的左右子树都存在,则跳过)补充成一个叶结点(外结点)。如图:
其中的方块表示扩充的叶结点(即外结点)。
我们定义:外通路长度为,从根结点到每个外结点的路径长度之和,内通路长度为,从根结点到每个内结点的路径长度之和。
根据上图,我们可以计算出外通路长度为2+2+3+3+2=12,内通路长度为1+0+1+2=4。
最优二叉树则是为扩充二叉树中扩充出来的叶结点赋值(为权值),然后找到加权外通路长度最小的扩充二叉树。
如图则是一种最优二叉树:
只要找到每一个外结点对应的外通路,这两者之和最小即可。
关于哈夫曼树,我们针对一个具体问题来进行分析:
假设现在有7个节点,我们将每一个结点视为一颗二叉树的根结点,由此组成一个森林,把结点在文件中出现的次数定义为该结点的权值。
如此,我们可以进行合并,具体如下:
-
在森林中取权值最小的两个根结点s和n,合并成一棵二叉树,并生成一个新结点T1,作为其二者的父结点,T1的权值是其两个子结点的权值之和,森林中减少一棵树。
-
重复上述操作,直到森林中只剩一棵树,算法结束。
上图就是问题所对应的哈夫曼树了。
5. 总结
二叉树只是树的一种特殊结构,且其应用是非常广泛的,树可以和二叉树进行转换,二叉树也可以转换成对应的树,树的存储和二叉树的存储方式相同,只是由于其结点可能不止左右两个孩子,所以其存储结构又不同,但是我们也有对应的方法去解决,如Father数组,孩子链表,左孩子右兄弟的链表结构(其实是转化为二叉树进行存储的)。
至于红黑树,二叉查找树这些,以后再慢慢讲吧。
网友评论