数据结构与算法——二叉树

非线性结构——树

树(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的节点n_{i}n_{i+1}的父亲。这条路径的长(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小得多,这个性质有时很重要。分析表明,其平均深度为O(\sqrt{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;
        }
    }
}
  • 二叉查找树的删除操作

二叉查找树的查找和删除操作都比较简单易懂,但是它的删除操作就比较复杂了。针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。

  1.  如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为NULL。
  2. 如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。
  3. 如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点,所以,我们可以应用上面两条规则来删除这个最小节点。

下面给出删除的实现代码:

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的范围是[log_{2}(n+1), log_{2}n+1]。完全二叉树的层数小于等于log_{2}n

显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点的左右子树都比较平衡的二叉查找树。这就是我们后面要学习的平衡二叉查找树。平衡二叉查找树的高度接近logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是O(logn)。

二叉查找树与散列表

我们在学习散列的时候,了解到散列表的插入、删除、查找操作的时间复杂度可以做到常量级的O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是O(logn),相对于散列表,好像二叉查找树并没有什么优势,那为什么还有用二叉查找树呢?下面总结了几个原因:

  • 第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,只需中序遍历,就可以在O(n)的时间复杂度内,输出有序的数据序列。
  • 第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能很不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在O(logn)。
  • 第三,笼统的来说,尽管散列表的查找等操作的时间复杂度是常量级的,但是因为散列冲突的存在,这个常量不一定比logn小,所以实际查找的速度可能不一定比O(logn)快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
  • 第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计,冲突解决方法,扩容,缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。

最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,因此会浪费一定的存储空间。综合以上几点,平衡二叉查找树在某些方面还是优于散列表的,所以这两者的存在并不冲突。我们在实际开发过程中,需要结合具体的需求来选择使用哪一个。

猜你喜欢

转载自blog.csdn.net/weixin_42570248/article/details/88559808