非线性结构——树
树(Tree)
树(tree)可以用几种方式定义。定义树的一种自然的方式是递归的方式。一棵树是一些结点(node)的集合。这个集合可以是空集;若不是空集,则树由称作根(root)的结点r以及0个或多个非空的(子)树T1,T2,...,TK组成,这些子树中每一颗的根都被来自根r的一条有向边(edge)所连接。每一颗子树的根叫作根r的儿子(child),而r是每一颗子树的根的父亲(parent)。
从递归定义中,我们发现,一棵树是N个节点和N-1条边的集合,其中的一个节点叫作根。存在N-1条边的结论是由下面的事实得出的:每条边都将某个节点连接到它的父亲,而去除根节点外每一个节点都有一个父亲。
在下图的树中,节点A是根。节点F有一个父亲A并且有儿子K、L和M。每一个节点可以有任意多个儿子,也可能是零个儿子。没有儿子的节点称为叶子(leaf),图中的叶子有B、C、H、I、P、Q、K、L、M和N。具有相同父亲的节点为兄弟(siblings)。因此,K、L和M都是兄弟。用类似的方法可以定义祖父(grandparent)和孙子(grandchild)关系。
从节点n1到nk的路径(path)定义为节点n1,n2,...,nk的一个序列使得对于1≤i<k的节点是的父亲。这条路径的长(length)是该路径上的边的条数,即k-1。从每一个节点到它自己有一条长为0的路径。注意,在一棵树中从根到每个节点恰好存在一条路径。
对任意节点ni,ni的深度(depth)为从根到ni的唯一路径的长。因此,根节点的深度为0。节点ni的高(height)是从ni到一片树叶的最长路径的长。因此所有树叶的高都是0。一棵树的高(height of a tree)等于它的根的高。对于上图的树,E的深度为1而高为2;F的深度为1而高也是1;该树的高为3。一颗树的深度(depth of a tree)等于其最深的树叶的深度,该深度总是等于这棵树的高。
如果存在从n1到n2的一条路径,那么n1是n2的一位祖先(ancestor),而n2是n1的一个后裔(descendant)。如果n1≠n2,那么n1是n2的真祖先(proper ancestor),而n2是n1的真后裔(proper descendant)。
二叉树(Binary Tree)
二叉树(binary tree)是一棵树,其中每个节点都不能有多于两个的儿子。
二叉树的一个性质是平均二叉树的深度要比节点个数N小得多,这个性质有时很重要。分析表明,其平均深度为,而对于特殊类型的二叉树,即二叉查找树(binary search tree),其平均深度为O(logN)。遗憾的是,二叉树的深度是可以大到N-1的。
因为一个二叉树节点最多有两个儿子,所有我们就可以保存直接链接到它们的链。树节点的声明在结构上类似于双向链表的声明,因为一个节点就是由element的信息加上两个指向左右子节点的指针组成的结构:
struct BinaryNode
{
Object element; // 节点上的数据
BinaryNode *left; // 左儿子
BinaryNode *right; // 右儿子
};
二叉树中有一种比较特殊的树叫作完全二叉树,这类树的特点是:叶子节点全都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这就是完全二叉树的特点。其中完全二叉树有一种特殊情况,就是当叶子节点全部都在最后一层,并且除了叶子节点之外,每个节点都有左右两个子节点,这种完全二叉树又称满二叉树。
在表示这类二叉树的时候,我们可以通过数组的顺序存储法。我们把根节点存储在下标为i=1的位置,那左子节点存储在下标2*i=2的位置,右子节点存储在2*i+1=3的位置,以此类推。如果节点X存储在数组中下标为i的位置,那下标为2*i的位置存储的就是左子节点,下标为2*i+1的位置存储的就是右子节点。反过来,下标为i/2的位置存储的就是它的父节点。但是如果不是完全二叉树的话,会浪费很多的数组存储空间。通过数组存储的方式并不需要像链式存储那样,要存储额外的左右子节点的指针。这也是为什么要单独讲完全二叉树的原因,以及为什么完全二叉树要求最后一层的子节点都靠左的原因。
二叉树的遍历
我们以及了解了二叉树的基本定义和存储方法,现在来看二叉树的遍历操作。通常我们有三种遍历方法,前序遍历、中序遍历和后序遍历。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序。
- 前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
- 中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
- 中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。
前序遍历:A->B->D->E->C->F->G
中序遍历:D->B->E->A->F->C->G
后序遍历:D->E->B->F->G->C->A
实际上,二叉树的前、中、后序遍历就是一个递归的过程。比如前序遍历,其实就是先打印根节点,然后递归打印左子树,最后递归的打印右子树。下面给出前、后、中序遍历的递归公式:
前序遍历的递推公式:
preOrder(r) = print r -> preOrder(r->left) -> preOrder(r->right);
中序遍历的递推公式:
inOrder(r) = inOrder(r->left) -> print r -> inOrder(r->right);
后序遍历的递推公式:
postOrder(r) = postOrder(r->left) -> postOrder(r->right) -> print r;
有了递推公式,接下来给出三种遍历的递归实现代码:
void preOrder(Node *root)
{
if (root == null) return;
print root;
preOrder(root->left);
preOrder(root->right);
}
void inOrder(Node *root)
{
if (root == null) return;
inOrder(root->left);
print root;
inOrder(root->right);
}
void postOrder(Node *root)
{
if (root == null) return;
postOrder(root->left);
postOrder(root->right);
print root;
}
这里分析一下二叉树遍历的时间复杂度,由遍历过程我们能够知道,每个节点最多会被访问2次,所以遍历操作的时间复杂度,根节点的个数n成正比,也就是说二叉树遍历的时间复杂度是O(n)。
其实二叉树还有一种遍历方式,层序遍历(level-order traversal),这种遍历不经常使用。在层序遍历中,所有深度为d的节点要在深度d+1的节点之前进行处理。层序遍历与其他类型的遍历不同的地方在于它不是递归的实施的,它用到队列,而不使用递归所默认的栈。
二叉查找树(Binary Search Tree)
二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了实现快速查找而生的。不过,它不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。这些都依赖于二叉查找树的特殊结构。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
下面依次来看,二叉查找树的查找、插入、删除操作是如何实现的:
- 二叉查找树的查找操作
首先,我们看如何 二叉查找树中查找一个节点。我们先取根节点,如果它等于我们要查找的数据,那就返回。如果查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。下面给出查找的递归实现代码:
Node* find(Node *root, int data)
{
Node *p = root;
while (p != NULL)
{
if (data < p->data) p = p->left;
else if (data > p->data) p = p->right;
else return p;
}
return NULL;
}
- 二叉查找树的插入操作
二叉查找树的插入过程有点类似查找操作。新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。下面给出插入的递归实现代码:
void insert(Node *root, int data)
{
if (root == NULL)
{
root = new Node(data);
return;
}
Node *p = root;
while (p != null)
{
if (data > p->data)
{
if (p->right == NULL)
{
p->right = new Node(data);
return;
}
p = p->right;
}
else
{
if (p->left == NULL)
{
p->left = new Node(data);
return;
}
p = p->left;
}
}
}
- 二叉查找树的删除操作
二叉查找树的查找和删除操作都比较简单易懂,但是它的删除操作就比较复杂了。针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。
- 如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为NULL。
- 如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。
- 如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点,所以,我们可以应用上面两条规则来删除这个最小节点。
下面给出删除的实现代码:
void delete(Node *root, int data)
{
Node *p = root; // p指向要删除的节点,初始化指向根节点
Node *pp = NULL; // pp记录的是p的父节点
while (p != NULL && p->data != data)
{
pp = p;
if (data > p->data) p = p->right;
else p = p = p->left;
}
if (p == NULL) return; // 没有找到
// 要删除的节点有两个子节点
if (p->left != NULL && p->right != NULL) // 查找右子树中最小的节点
{
Node *minP = p->right; // 右子树中最小的节点
Node *minPP = p; // minPP表示minP的父节点
while (minP.left != NULL)
{
minPP = minP;
minP = minP->left;
}
p->data = minP.data; // 将minP的数据替换到p中
p = minP; // 下面就变成删除minP了
pp = minPP;
}
// 删除节点是叶子节点或者仅有一个子节点
Node *child; // p的子节点
if (p->left != NULL) child = p->left;
else if (p-> != NULL) child = p->right;
else child = NULL;
if (pp = NULL) root = child; // 删除的是根节点
else if (pp->left == p) pp.left = child;
else pp->right = child;
}
实际上,关于二叉查找树的删除操作,还有个非常简单,巧取的方法,就是单纯的将要删除的节点标记为”已删除“,但是并不是真正从树种将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且这种处理方法也没有增加插入、查找操作代码实现的难度。
- 二叉查找树的其他操作
除了插入、删除、查找操作之外,二叉查找树中还可以支持快速的查找最大节点和最小节点、前驱结点和后继节点。除了以上这些操作,二叉查找树还有一个重要的特性,就是中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是O(n),非常高效。因此二叉查找树也叫做二叉排序树。
支持重复数据的二叉查找树
之前所讲的二叉查找树,我们默认树中节点存储的都是数字。很多时候,在实际的软件开发中,我们在二叉查找树中存储的,是一个包含很多字段的对象。我们利用对象的某个字段作为键值(key)来构建二叉查找树。我们把对象中的其他字段叫作卫星数据。之前所了解的二叉查找树的操作,针对的都是不存在键值相同的情况。如果存储的两个对象键值相同,又该怎么处理。这里给出两种解决方法。
第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
第二种方法比较不好理解,但是更加实用。每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就讲这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点为止。这样就可以把键值等于要查找值的所有节点都找出来。对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作方法,依次删除。
二叉查找树的时间复杂度分析
实际上,二叉查找树的形态各式各样。对于同一组数据,我们能构造出不同的二叉查找树。它们的查找、插入、删除操作的执行效率都是不一样的。图中第一种二叉查找树,根节点的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了O(n)。
其实图中第一种情况是最糟糕的一种情况,我们现在分析一下最理想的情况,二叉查找树是一颗完全二叉树(或者满二叉树)。这个时候,插入、删除、查找的时间复杂度又是多少呢?我们从前面的例子还有图以及代码来看,不管是什么操作,时间复杂度其实都根树的高度成正比,也就是O(height)。既然这样,那么问题就转变成为另外一个,也就是,如何求一颗包含n个节点的完全二叉树的高度。树的高度就等于最大层数减一,为了方便计算,我们转换成层来表示。从图中就可以看出,包含n个节点的完全二叉树中,第一层包含1个节点,第二层包含2个节点,第三层包含4个节点,依此类推,下面一层节点的个数是上一层的2倍,第k层包含的节点就是2^(k-1)。不过对于完全二叉树来说,最后一层的节点个数就不遵循上面的规律了,它包含的节点个数在1到2^(L-1)个之间(L为最大层数)。如果我们把每一层的结点数加起来就是总的节点个数n。也就是说n满足以下关系式:
n >= 1+2+4+8+...+2^(L-2)+1
n <= 1+2+4+8+...+2^(L-2)+2^(L-1)
借助等比数列求和公式,我们可以计算出L的范围是。完全二叉树的层数小于等于。
显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点的左右子树都比较平衡的二叉查找树。这就是我们后面要学习的平衡二叉查找树。平衡二叉查找树的高度接近logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是O(logn)。
二叉查找树与散列表
我们在学习散列的时候,了解到散列表的插入、删除、查找操作的时间复杂度可以做到常量级的O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是O(logn),相对于散列表,好像二叉查找树并没有什么优势,那为什么还有用二叉查找树呢?下面总结了几个原因:
- 第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,只需中序遍历,就可以在O(n)的时间复杂度内,输出有序的数据序列。
- 第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能很不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在O(logn)。
- 第三,笼统的来说,尽管散列表的查找等操作的时间复杂度是常量级的,但是因为散列冲突的存在,这个常量不一定比logn小,所以实际查找的速度可能不一定比O(logn)快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
- 第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计,冲突解决方法,扩容,缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,因此会浪费一定的存储空间。综合以上几点,平衡二叉查找树在某些方面还是优于散列表的,所以这两者的存在并不冲突。我们在实际开发过程中,需要结合具体的需求来选择使用哪一个。