数据结构与算法之美(笔记10)二叉树

比如下面这幅图,A节点是B节点的父节点,B节点是A节点的子节点。B、C、D这三个节点的父节点是同一个节点,所以它们之间互称兄弟节点。我们把没有父节点的节点叫做根节点。我们把子节点的节点叫做叶子节点或者叶节点。

另外,关于“树”,还有三个比较相似的概念:高度,深度,层。

 

二叉树

二叉树,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点右子节点

其中,编号为2的二叉树,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树叫做满二叉树

编号3的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫做完全二叉树。 

二叉树的存储

1.链式存储法

从图中,每个节点有三个字段,其中一个储存数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树串起来。

typedef struct tNode{
    int data;
    tNode* left;
    tNode* right;
    tNode(){
        data = NO_DATA;
        left = NULL;
        right = NULL;
    }
}tNode;

 2.顺序存储法

顺序存储法是基于数组实现的。如果根节点在下标为i=1的位置,那左子节点储存在下标2*i = 2的位置,右子节点在2*i +1 = 3的位置。以此类推,下标为i/2的位置储存就是它的父节点。

 当然这种方法是基于特殊的树的,这种树就是完全二叉树,所以这就是我们单独拿出来说明的原因。

二叉树的遍历

先根遍历,先打印根节点,再打印左子树,最后打印它的右子树。

中根遍历,先打印它的左子树,再打印它本身,最后打印它的右子树。

后根遍历,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。

按层遍历,需要使用队列。

这里给出代码的实现:

void preOrder_c(tNode* p){
        if(p == NULL) return;
        cout << p->data << endl;
        preOrder_c(p->l_child);
        preOrder_c(p->r_child);
    }

    void preOrder(){
        tNode* p = root;
        preOrder_c(p);
    }

    void inOrder_c(tNode* p){
        if(p == NULL) return;
        inOrder_c(p->l_child);
        cout << p->data << endl;
        inOrder_c(p->r_child);
    }

    void inOrder(){
        tNode* p = root;
        inOrder_c(p);
    }

    void postOrder_c(tNode* p){
        if(p == NULL) return;
        postOrder_c(p->l_child);
        postOrder_c(p->r_child);
        cout << p->data << endl;
    }

    void postOrder(){
        tNode* p = root;
        postOrder_c(p);
    }
    void levelOrder(){
        queue<tNode*> Q;
        Q.push(root);
        while(!Q.empty()){
            tNode* p = Q.front();
            cout << p->data << endl;
            Q.pop();
            if(p->left !=NULL){
                Q.push(p->left);
            }
            if(p->right != NULL){
                Q.push(p->right);
            }
        }
    }

二叉查找树

二叉查找树不仅适合快速查找,而且支持快速插入,删除一个数据。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

1.查找

首先,我们在二叉查找树中查找一个节点,我们取根节点,如果等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找,如果要查找的数据比根节点的值大,那就在右子树递归查找。

2.插入操作

如果要插入的数据比节点的数据大,而且节点的右子树为空,就将新数据直接插到右子节点的位置。如果不为空,就再递归遍历右子树,查找插入数据。同理,如果要插入的数据比节点数值小,而且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。

3.删除操作

针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。

第一种情况:如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为null,比如途中的删除节点55。

第二种情况:如果要删除的节点只有一个子节点,我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点13。

第三种情况:如果要删除的节点有两个子节点,我们需要找到这个节点右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点,所以,我们可以应用上面两条规则来删除这个最小节点。比如删除图中的节点18。

 

实际,关于删除,有个比较简单的实现方法,就是单纯将要删除的节点标记为“已经删除” ,但是并不真正从树中将这个节点去掉。虽然比较浪费空间,但是删除操作就变得简单了很多。

这里给出代码的实现:

class Tree{
private:
    tNode* root = NULL;
public:
    void insert(int elem){
        if(root == NULL){// 根结点不存在
            root = new tNode;
            root->data = elem;
        }else{
            tNode* p = root;
            tNode* ppre = root;
            while(p!=NULL){
                ppre = p;
                if(elem >= p->data){
                    p = p->right;
                }else{
                    p = p->left;
                }
            }
            if(elem >= ppre->data && ppre->right == NULL){
                tNode* newNode = new tNode;
                newNode->data = elem;
                ppre->right = newNode;
            }else if(elem < ppre->data && ppre->left == NULL){
                tNode* newNode = new tNode;
                newNode->data = elem;
                ppre->left = newNode;
            }
        }
    }
    void find(int elem){
        tNode* p = root;
        while(p!=NULL){
            if(p->data == elem){
                cout << p->data << endl;
                p = p->right;// 重复数据处理方法
            }else if(elem > p->data){
                p = p ->right;
            }else{
                p = p->left;
            }
        }
    }
    void Delete(int elem){
        tNode* p = root;
        tNode* ppre = NULL;
        while(p!=NULL && elem != p->data){
            ppre = p;
            if(elem > p->data){
                p = p->right;
            }else{
                p = p->left;
            }
        }
        if(p == NULL) return;// 没有找到

        // 有两个叶子结点,通过交换数据来变成只删除叶子结点
        if(p->left!=NULL && p->right!=NULL){
            tNode* minp = p->right;
            tNode* minpp = p;
            while(minp->left != NULL){
                minpp = minp;
                minp = minp->left;
            }
            p->data = minp->data;
            p = minp;
            ppre = minpp;
        }

        // 叶子结点或者只有一个子结点
        tNode* child;
        if(p->left != NULL) child = p->left;
        else if(p->right != NULL) child = p->right;
        else child = NULL;

        if(ppre == NULL) root = child;// 根结点
        else if(ppre->left == p) ppre->left = child;
        else ppre->right = child;
    }
};

4.其他操作

快速地查找最大节点和最小节点,前驱节点和后继节点。

另外,二叉查找树还有一个重要的特性,就是中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是O(n),非常高效。

    tNode* pre_Node(tNode* find){
        int elem = find->data;
        tNode* p = root;
        tNode* pp = root;
        while(p != NULL && p->data!=elem){
            pp = p;
            if(elem > p->data) p = p->r_child;
            else p = p->l_child;
        }
        if(p == root) return NULL;
        return pp;
    }

 

支持重复数据的二叉查找树

前面我们默认树中节点储存的都是数字。很多时候,在实际的软件开发中,我们在二叉查找树中存储的,是一个包含很多字段的对象。我们利用对象的某个字段作为键值来构建二叉树。我们把对象中的其他字段叫作卫星数据。

如果存储的两个对象键值相同,这种情况如何处理?

第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此,我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都储存在同一个节点上。

第二种方法,每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。

当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。

对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。

二叉查找树的时间复杂度分析 

实际,二叉查找树的形态各种各样,他们的查找,删除,插入的执行效率是不一样的。图中的第一种二叉查找树,极不平衡,它查找的时间复杂度就变成了O(n),这是最坏时间复杂度。

最好的情况,这个树应该是一颗满二叉树,从刚才的算法中看,不论是查找,删除,插入,时间复杂度其实都跟树的高度成正比,也就是O(height)。一颗树的高度为O(logN)。如果我们能够构建一种任何时刻都保持平衡的二叉树,那么他的查找,删除,插入的时间复杂度就是稳定的O(logn)。

二叉查找树和散列表的区别

散列表的插入,删除,查找操作的时间复杂度可以做到常量级的O(1)。而二叉查找树是O(logn),那优势在哪?

1.散列表的数据是无序储存的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树,只需要中序遍历,就可以在O(n)时间复杂度内完成。

2.散列表扩容耗时多,而且容易散列冲突,性能不稳定。二叉查找树中的平衡二叉树很稳定。

3.尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比logn小,所以实际的查找速度可能不一定比o(logn)快,加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。

4.散列表的构造比二叉查找树复杂多,要考虑的东西很多,散列函数的设计,冲突解决方法,扩容,缩容。平衡二叉树只需要考虑平衡性。

最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的储存空间。

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值