挖掘算法中的数据结构:二分搜索树(删除广度优先遍历顺序性)及 衍生算法问题

Posted 鸽一门

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了挖掘算法中的数据结构:二分搜索树(删除广度优先遍历顺序性)及 衍生算法问题相关的知识,希望对你有一定的参考价值。

上篇博文介绍了二分查找算法和二分搜索树的基本操作,如插入、查找、深度优先遍历,此篇博文将要介绍二分搜索树的广度优先遍历、顺序性、局限性等相关知识,还有二分搜索树中最复杂的部分——删除节点及衍生的算法知识,涉及到的知识点如下:

  • 层序遍历(广度优先遍历)
  • 删除最大值,最小值、删除节点
  • 二分搜索树的顺序性
  • 二分搜索树的局限性
  • 树形问题和更多树。

挖掘算法中的数据结构(一):选择、插入、冒泡、希尔排序 及 O(n^2)排序算法思考
挖掘算法中的数据结构(二):O(n*logn)排序算法之 归并排序(自顶向下、自底向上) 及 算法优化
挖掘算法中的数据结构(三):O(n*logn)排序算法之 快速排序(随机化、二路、三路排序) 及衍生算法
挖掘算法中的数据结构(四):堆排序之 二叉堆(Heapify、原地堆排序优化)
挖掘算法中的数据结构(五):排序算法总结 和 索引堆及优化(堆结构)
挖掘算法中的数据结构(六):二分查找 和 二分搜索树(插入、查找、深度优先遍历)


一. 层序遍历(广度优先遍历)

1. 算法思想

在此需要引入两个概念:深度优先遍历和广度优先遍历,而以上讲解的前序、中序、后序遍历都属于深度优先遍历,遍历一开始首先会走到最深,再回溯到开始遍历整棵树。

广度优先遍历则是层序遍历,一层一层地向下遍历,查看以下动画:

2. 代码实现

查看以上动画,实现其过程需要引入先进先出的“队列”数据结构,首先将28入队,第一层遍历完毕,可进行操作,将28出队并打印。遍历第二层16、30依次入队,再出队进行打印操作,依次类推。

public:
    // 二分搜索树的层序遍历
    void levelOrder()

        queue<Node*> q;
        q.push(root);//入队根节点
        while( !q.empty() )//队列为空时结束循环

            Node *node = q.front();//获取队首元素
            q.pop();

            cout<<node->key<<endl;

            if( node->left )
                q.push( node->left );
            if( node->right )
                q.push( node->right );
        
    

3. 广度、深度优先遍历总结

不论是二分搜索树的深度优先遍历还是广度优先遍历,性能都是很高效的,为O(n),基本上是最小的了,毕竟“遍历”至少需要每个节点遍历一次。在很多实际运用中可能不需要显示地构建出一棵树,但是需要遍历一次树中节点。

而之前学过的排序算法例如归并排序、快速排序本质上是一棵二叉树的深度优先遍历过程,此二分搜索树也是通过递归等基本内容构造的一棵复杂的数结构,可见算法与数据结构之间的互相依赖。




二. 二分搜索树的删除

二分搜索树中最复杂的操作——删除节点,其实此过程中的查找需删除节点和删除操作并不复杂,复杂的是如何操作删除之后节点的左右孩子,使得最后整棵树依然保持二分搜索树的性质。

1. 删除二分搜索树的最小值和最大值

(1)算法思想

查找过程

首先来了解最简单的情况—–删除二分搜索树的最小值和最大值,其实此过程根据搜索树的特征很容易解决,从根节点开始遍历其左孩子,直至最后节点无左孩子,那么此节点就是最小值;最大值同理,遍历其右孩子即可。

删除过程

注意,这里二分搜索数的最小、大值并非一定完全二叉树下的情况,例如下图,所以在删除节点时,需要将其左孩子或右孩子代替其删除节点,来保持二分搜索树的特征。

举个例子,需要删除下图二分搜索树的最小值22,删除22后,22必然没有左孩子,因为它已经是最小值,将其右孩子33代替22的位置,返回节点33。删除最大值同理

(2)代码实现

公有函数,供外层调用:

  • minimum():寻找二分搜索树的最小的键值
  • maximum():寻找二分搜索树的最大的键值
  • removeMin():从二分搜索树中删除最小值所在节点
  • removeMax():从二分搜索树中删除最大值所在节点

私有函数,内部实际操作:

  • Node* minimum(Node* node):返回以node为根的二分搜索树的最小键值所在的节点
  • Node* maximum(Node* node):返回以node为根的二分搜索树的最大键值所在的节点
  • Node* removeMin(Node* node):删除掉以node为根的二分搜索树中的最小节点,返回删除节点后新的二分搜索树的根
  • Node* removeMax(Node* node):删除掉以node为根的二分搜索树中的最大节点,返回删除节点后新的二分搜索树的根
public:
    // 寻找二分搜索树的最小的键值
    Key minimum()
        assert( count != 0 );
        Node* minNode = minimum( root );
        return minNode->key;
    

    // 寻找二分搜索树的最大的键值
    Key maximum()
        assert( count != 0 );
        Node* maxNode = maximum(root);
        return maxNode->key;
    

    // 从二分搜索树中删除最小值所在节点
    void removeMin()
        if( root )
            root = removeMin( root );
    

    // 从二分搜索树中删除最大值所在节点
    void removeMax()
        if( root )
            root = removeMax( root );
    

private:
        // 返回以node为根的二分搜索树的最小键值所在的节点
    Node* minimum(Node* node)
        if( node->left == NULL )
            return node;

        return minimum(node->left);
    

    // 返回以node为根的二分搜索树的最大键值所在的节点
    Node* maximum(Node* node)
        if( node->right == NULL )
            return node;

        return maximum(node->right);
    

    // 删除掉以node为根的二分搜索树中的最小节点
    // 返回删除节点后新的二分搜索树的根
    Node* removeMin(Node* node)

        if( node->left == NULL )

            Node* rightNode = node->right;
            delete node;
            count --;
            return rightNode;
        

        node->left = removeMin(node->left);
        return node;
    

    // 删除掉以node为根的二分搜索树中的最大节点
    // 返回删除节点后新的二分搜索树的根
    Node* removeMax(Node* node)

        if( node->right == NULL )

            Node* leftNode = node->left;
            delete node;
            count --;
            return leftNode;
        

        node->right = removeMax(node->right);
        return node;
    

2. 删除节点

(1)算法思想

以上是删除节点的特殊情况,可以确定待删除节点只有1个孩子或者没有,所以在删除此节点之后,其孩子可以顶替,这样仍维护了二分搜索树的特征,如下图示例:

但是,以上讨论的是特殊情况,若待删除节点58同时拥有左、右孩子,该如何操作?

Hubbard Deletion

以下介绍的算法被称为Hubbard Deletion,在之前的讨论中,若待删除节点只有一个孩子,则用此孩子替代待删除节点;若有两个孩子,其思想也是类似,找到一个合适的节点来替代,而Hubbard Deletion算法则认为此替代节点是右子树的最小节点!

因此,需要代替58的节点是59,注意二分搜索树的特征,59的所有右孩子都比58要大,所以右孩子子树中的最小值59代替其58后,此二分搜索树的特征仍然成立!

因此,整个过程可以总结为首先寻找待删除节点的后继节点右子树中的最小值),由后继节点代替待删除节点即可。

(2)代码实现

若要删除左右都有孩子的节点 d

  • 找到 s = min(d->right),s 是 d 右子树中的最小值,需要代替d的后继节点
  • s->right = delMin(d->right)
  • s->left = d->left
  • 删除d,s是新的子树的根
public:
    // 从二分搜索树中删除键值为key的节点
    void remove(Key key)
        root = remove(root, key);
    

private:
    // 删除掉以node为根的二分搜索树中键值为key的节点, 递归算法
    // 返回删除节点后新的二分搜索树的根
    Node* remove(Node* node, Key key)

        if( node == NULL )
            return NULL;//未找到对应key的节点

        if( key < node->key )//在node的左子树中寻找
            node->left = remove( node->left , key );
            return node;
        
        else if( key > node->key )//在node的右子树中寻找
            node->right = remove( node->right, key );
            return node;
        
        else   // key == node->key

            // 待删除节点左子树为空的情况
            if( node->left == NULL )
                Node *rightNode = node->right;
                delete node;
                count --;
                return rightNode;
            

            // 待删除节点右子树为空的情况
            if( node->right == NULL )
                Node *leftNode = node->left;
                delete node;
                count--;
                return leftNode;
            

            // 待删除节点左右子树均不为空的情况
            // 找到比待删除节点大的最小节点, 即待删除节点右子树的最小节点
            // 用这个节点顶替待删除节点的位置
            Node *successor = new Node(minimum(node->right));
            count ++;

            successor->right = removeMin(node->right);
            successor->left = node->left;

            delete node;
            count --;

            return successor;
        
    

注意:以上过程可完成二分搜索树的节点删除过程,其重点就是当待删除节点同时拥有左、右子树时,寻找右子树中的最小值进行代替。其实同理而言,另外一个思路也可实现:寻找左子树的最大值进行代替,如下图所示,这种特性来源于二分搜索树的特征,可自行实现。


3. 总结

二分搜索树的删除操作时间复杂度为O(logn),主要消耗于查找待删除节点,一旦找到了删除节点的过程只是指针间的交换,是常数级别的,是非常高效的。




三. 二分搜索树的顺序性

以上内容讲解了二分搜索树的各种操作,大部分情况将二分搜索树当作查找表来实现,主要关注的是如何查找一个key对应的value值,同时完成插入、删除、查找、遍历所有元素等操作,注意二分搜索树还有一个重要的特征:顺序性,也就是说不仅可以在二分搜索树中定位一个元素,还可以回答其顺序性相关的问题:

  • minimum , maximum:已经实现,非常容易可在一组数据中找到最小、大值。
  • successor , predecessor:待实现,可找到一个元素的前驱节点和后继节点。
  • floor , ceil:待实现

(在此只详细讲解以上内容,有关顺序性还有rank、select相关操作,读者可先自行思考,日后补充)


1. 前驱节点和后继节点(successor , predecessor)

(1)算法思想

首先需要理解清楚前驱节点和后继节点的定义,几个例子,下图中 41的前驱节点是 37,后继节点是42。

因此,规律也自然而然得出:

  • 一个节点的前驱节点是其左子树中的最大值,若无左子树,其前驱节点在从根节点到key的路径上,比key小的最大值
  • 一个节点的后继节点是右子树的最小值,若无右子树,其后继节点在从根节点到key的路径上,比key大的最小值

(2)代码实现

这里寻找前驱节点或后继节点的逻辑主要分为3个步骤(这里只列出寻找前驱节点的步骤,后继节点同理,在此不赘述):

  • 如果key所在的节点不存在,则key没有前驱, 返回NULL
  • 如果key所在的节点左子树不为空,则其左子树的最大值为key的前驱
  • 否则,key的前驱在从根节点到key的路径上,在这个路径上寻找到比key小的最大值, 即为key的前驱
public:
    // 查找key的前驱
    // 如果不存在key的前驱(key不存在, 或者key是整棵二叉树中的最小值), 则返回NULL
    Key* predecessor(Key key)

        Node *node = search(root, key);
        // 如果key所在的节点不存在, 则key没有前驱, 返回NULL
        if(node == NULL)
            return NULL;

        // 如果key所在的节点左子树不为空,则其左子树的最大值为key的前驱
        if(node->left != NULL)
            return &(maximum(node->left)->key);

        // 否则, key的前驱在从根节点到key的路径上, 在这个路径上寻找到比key小的最大值, 即为key的前驱
        Node* preNode = predecessorFromAncestor(root, key);
        return preNode == NULL ? NULL : &(preNode->key);
    

    // 查找key的后继, 递归算法
    // 如果不存在key的后继(key不存在, 或者key是整棵二叉树中的最大值), 则返回NULL
    Key* successor(Key key)

        Node *node = search(root, key);
        // 如果key所在的节点不存在, 则key没有前驱, 返回NULL
        if(node == NULL)
            return NULL;

        // 如果key所在的节点右子树不为空,则其右子树的最小值为key的后继
        if(node->right != NULL)
            return &(minimum(node->right)->key);

        // 否则, key的后继在从根节点到key的路径上, 在这个路径上寻找到比key大的最小值, 即为key的后继
        Node* sucNode = successorFromAncestor(root, key);
        return sucNode == NULL ? NULL : &(sucNode->key);
    

private:
    // 在以node为根的二叉搜索树中, 寻找key的祖先中,比key小的最大值所在节点, 递归算法
    // 算法调用前已保证key存在在以node为根的二叉树中
    Node* predecessorFromAncestor(Node* node, Key key)

        if(node->key == key)
            return NULL;

        Node* maxNode;
        if(key < node->key)
            // 如果当前节点大于key, 则当前节点不可能是比key小的最大值
            // 向下搜索到的结果直接返回
            return predecessorFromAncestor(node->left, key);
        else
            assert(key > node->key);
            // 如果当前节点小于key, 则当前节点有可能是比key小的最大值
            // 向下搜索结果存储到maxNode中
            maxNode = predecessorFromAncestor(node->right, key);
            if(maxNode)
                // maxNode和当前节点node取最大值返回
                return maxNode->key > node->key ? maxNode : node;
            else
                // 如果maxNode为空, 则当前节点即为结果
                return node;
        
    

    // 在以node为根的二叉搜索树中, 寻找key的祖先中,比key大的最小值所在节点, 递归算法
    // 算法调用前已保证key存在在以node为根的二叉树中
    Node* successorFromAncestor(Node* node, Key key)

        if(node->key == key)
            return NULL;

        Node* minNode;
        if(key > node->key)
            // 如果当前节点小于key, 则当前节点不可能是比key大的最小值
            // 向下搜索到的结果直接返回
            return successorFromAncestor(node->right, key);
        else
            assert(key < node->key);
            // 如果当前节点大于key, 则当前节点有可能是比key大的最小值
            // 向下搜索结果存储到minNode中
            minNode = predecessorFromAncestor(node->left, key);
            if(minNode)
                // minNode和当前节点node取最小值返回
                return minNode->key < node->key ? minNode : node;
            else
                // 如果minNode为空, 则当前节点即为结果
                return node;
        
    

2. floor , ceil

(1)算法思想

寻找floor , ceil不同于上部分所讲的,寻找一个节点的前驱节点和后继节点首先有个前提就是要保证此节点一定存在,但是寻找floor , ceil无需保证。

  • 若key值存在,那么floor , ceil就是key值自身。
  • 若key值不存在:
    • floor:是最接近key值且**小于**key的节点
    • ceil:是最接近key值且**大于**key的节点

例如下图,举几个例子来了解:

  • 节点41的floor , ceil是41;
  • 45的floor是42,ceil是50;
  • 64无ceil,floor是61;
  • 11无floor,ceil是13。

(2)代码实现

这里寻找floor 或 ceil 的逻辑主要分为3个步骤(这里只列出寻找floor 的步骤,ceil 同理,在此不赘述):

  • 如果node的key值和要寻找的key值相等:则node本身就是key的floor节点。
  • 如果node的key值比要寻找的key值大:则要寻找的key的floor节点一定在node的左子树中。
  • 如果node的key值比要寻找的key值小:则node有可能是key的floor节点, 也有可能不是(存在比node->key大但是小于key的其余节点),需要尝试向node的右子树寻找一下。
public:
     // 寻找key的floor值, 递归算法
    // 如果不存在key的floor值(key比BST中的最小值还小), 返回NULL
    Key* floor(Key key)

        if( count == 0 || key < minimum() )
            return NULL;

        Node *floorNode = floor(root, key);
        return &(floorNode->key);
    

    // 寻找key的ceil值, 递归算法
    // 如果不存在key的ceil值(key比BST中的最大值还大), 返回NULL
    Key* ceil(Key key)

        if( count == 0 || key > maximum() )
            return NULL;

        Node *ceilNode = ceil(root, key);
        return &(ceilNode->key);
    

private:
    // 在以node为根的二叉搜索树中, 寻找key的floor值所处的节点, 递归算法
    Node* floor(Node* node, Key key)

        if( node == NULL )
            return NULL;

        // 如果node的key值和要寻找的key值相等
        // 则node本身就是key的floor节点
        if( node->key == key )
            return node;

        // 如果node的key值比要寻找的key值大
        // 则要寻找的key的floor节点一定在node的左子树中
        if( node->key > key )
            return floor( node->left , key );

        // 如果node->key < key
        // 则node有可能是key的floor节点, 也有可能不是(存在比node->key大但是小于key的其余节点)
        // 需要尝试向node的右子树寻找一下
        Node* tempNode = floor( node->right , key );
        if( tempNode != NULL )
            return tempNode;

        return node;
    


    // 在以node为根的二叉搜索树中, 寻找key的ceil值所处的节点, 递归算法
    Node* ceil(Node* node, Key key)

        if( node == NULL )
            return NULL;

        // 如果node的key值和要寻找的key值相等
        // 则node本身就是key的ceil节点
        if( node->key == key )
            return node;

        // 如果node的key值比要寻找的key值小
        // 则要寻找的key的ceil节点一定在node的右子树中
        if( node->key < key )
            return ceil( node->right , key );

        // 如果node->key > key
        // 则node有可能是key的ceil节点, 也有可能不是(存在比node->key小但是大于key的其余节点)
        // 需要尝试向node的左子树寻找一下
        Node* tempNode = ceil( node->left , key );
        if( tempNode != NULL )
            return tempNode;

        return node;
    



四. 局限性

1. 引出问题

在上篇博文中测试过二分搜索树的性能,以《圣经》文本为例,同顺序查找表(本质为链表)比较查找“god”词频的时间复杂度,结果十分明显,二分搜索树的性能更优,相差近乎一百陪。

但是二分搜索树一直是如此高效么?

试回忆之前讲解过的快速排序算法,它是O(n*logn)级别中最高效的算法,但是在极少情况下会退化到O(n^2)情况。因此二分搜索树也是它的局限性。

2. 局限性来源

它的局限性来源于哪?注意其二分搜索树的创建,如下图所示,同样的数据,可以对应不同的二分搜索树。

如上图,第一种创建情况可能是大部分人心中设想,但是第二种情况也是符合二分搜索树的特征,如此一来,二分搜索树可能退化成链表。二分搜索树的查找过程是与其高度相关,此时高度为n,时间复杂度为O(n^2)

3. 测试

下面进行一个测试:选用马克思所写的《共产主义宣言》文本,统计“unite”词频,使用正常二分搜索树BST、顺序查找表SST、极端二分搜索树BST2分别进行测试,结果如下:
(测试源码请查看github)

分析:

对比BST 和 BST2算法所消耗的时间,两个二分搜索树只是在创建时插入节点的顺序不同,消耗时间相差百倍,性能的损失是非常明显的。

可是BST2怎么会比顺序查找表SST还要慢呢?

首先顺序查找表SST本质是用链表实现,只需要处理一个指针,BST2虽然退化成链表,但是在实现操作过程中还是有左孩子的概念,所以需要一直判断左孩子为空的情况,并且BST采用递归方式实现,其中的入栈出栈操作本身就会比链表中迭代的实现方式满。因此,以上两个因素累积起来,造成BST2性能损耗严重。


4. 总结

其实二分搜索树的性能总体而言还是十分优异的,它所有的有关操作时间复杂度为O(n),出现以上情况的概率很小,但如果创建时其数据都是有序的,那么就会令人担忧了。也许你会想到快速排序中也有此问题,不过它通过随机获取标志点的方法解决了此问题。

所以类似以上解决办法,将其顺序打乱再插入到二分搜索树即可?这是一个解决办法,但是需要一开始获取所有数据,其实这些数据是慢慢流入系统的,所以在创建其过程中才会发现数据是否几乎有序。

为了解决此问题,可以改造二叉树的实现,使得其无法退化成链表—–平衡二叉树,它有左右两棵子树,并且其高度差不会超过1,因此可以保证其高度一定是 logn 级别的,此概念的经典实现就是红黑树

如上图,红黑树中有两类节点:红色节点和黑色节点,在插入、删除等操作中会考虑节点的颜色进行处理,是一种很经典的数据结构。(读者可私下进行理解)




五. 树形问题和更多树

这两篇博文主要都在讲解如何构造一棵二分搜索树,你会发现在很多算法问题中虽然未要求创建出一棵树结构,但是在问题求解过程中会使用到树结构,这种结构含有天然的递归性质。

1. 排序问题

(1)归并排序

例如之前讲解排序算法中的归并排序,回忆其重点,将数组逐渐分成两个部分,分别排序,再逐渐归并起来。这整个过程归纳起来就是一棵树的情况,虽然在解决问题时并未创建树结构。

(2)快速排序

同理,快速排序中找到数组的标志点将其一分为二,在子数组中继续找到标志点再将其一份为二。其过程都是对数的一次遍历,类似于后序遍历或前序遍历。


2. 搜索问题

递归方式对于搜索问题更是尤为重要!事实上,绝大部分计算机问题都可以使用搜索方式解决。

(1)一条龙游戏

例如下图:“一条龙游戏”,在与电脑博弈的过程中,每次在九宫格走的e格子进行枚举,每次枚举会产生新的棋局,相当于上个棋局派生的子节点,再次派生其子节点,以此类推,产生决策树,选择最优可能。

(2)8皇后

经典的一个问题,在国际象棋中摆放8个皇后,使得横、竖、对角线之间
无重复元素。

(3)数独

(4)搬运工

树形搜索同样是人工智能中的一个重点,例如国际象棋中的人工智能核心就是搜索。


3. 各种各样的树

  • KD 树
  • 区间树
  • 哈夫曼树

树的应用十分广泛,以上第五点内容仅为扩展,有兴趣可自行深入探究。



所有以上解决算法详细代码请查看liuyubo老师的github:
https://github.com/liuyubobobo/Play-with-Algorithms


若有错误,虚心指教~

以上是关于挖掘算法中的数据结构:二分搜索树(删除广度优先遍历顺序性)及 衍生算法问题的主要内容,如果未能解决你的问题,请参考以下文章

十九 二分搜索树的广度优先遍历

算法——二分搜索树

二分搜索树的深度优先遍历和广度优先遍历

Python数据结构-队列与广度优先搜索(Queue)

06-二分搜索树 BST

倍道而行:二分搜索树的遍历前中后序遍历(深度优先遍历)+层序遍历广度优先遍历