挖掘算法中的数据结构(六):二分查找 和 二分搜索树(插入、查找、深度优先遍历)

此篇文章将介绍二叉搜索树(Binary Search Tree),同上篇文章介绍的二叉堆本质上类似,都是一个二叉树。选择什么特征的二叉树是根据具体问题来决定的,需谨记选择数据结构的核心在于解决问题,并非为了使用而使用,而是因为二叉树能够高效地解决某类问题。

此篇博文涉及的知识点如下:

  • 二分查找法
  • 二分搜索树基础
  • 二分搜索树的节点插入
  • 二分搜索书的查找
  • 二分搜索树的遍历(深度优先遍历)

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


一.查找问题

搜索树能够高效地解决查找问题,虽然查找问题的描述看起来十分简单,但它是计算机中重要的基础问题,应用很广泛。在进行搜索树讲解之前,先介绍一个经典查找算法 —– 二分查找(Binary Search)。

1. 限制

对于有序数列,才能使用二分查找法!在此体现出前面几篇文章介绍的排序算法的作用:可作为其它算法的子过程 。

2. 算法思想

注意该算法的前提条件:有序数组。例如下图,想查找元素value,先查看数组中间元素值v与value的大小,若相等则刚好,否则根据比较结果选择左、右半部分再次寻找。

整个查找过程可构成一棵树,时间复杂度为O(logn)。

这里写图片描述

3. 代码实现

// 二分查找法,在有序数组arr中,查找target
// 如果找到target,返回相应的索引index
// 如果没有找到target,返回-1
template<typename T>
int binarySearch(T arr[], int n, T target){
    // 在arr[l...r]之中查找target
    int l = 0, r = n-1;
    while( l <= r ){

        //int mid = (l + r)/2;
        // 防止极端情况下的整形溢出,使用下面的逻辑求出mid
        int mid = l + (r-l)/2;

        if( arr[mid] == target )
            return mid;

        if( arr[mid] > target )
            r = mid - 1;
        else
            l = mid + 1;
    }
    return -1;
}

查看以上代码,发现整个查找过程就是通过比较中间值大小,从而在其左部分或右部分查找,其实也是一个递归的过程,可通过递归实现,通常思维思考起来更容易,只是性能上会略差(常数级别)。

// 用递归的方式写二分查找法
template<typename T>
int __binarySearch2(T arr[], int l, int r, T target){

    if( l > r )
        return -1;

    //int mid = (l+r)/2;
    // 防止极端情况下的整形溢出,使用下面的逻辑求出mid
    int mid = l + (r-l)/2;

    if( arr[mid] == target )
        return mid;
    else if( arr[mid] > target )
        return __binarySearch2(arr, l, mid-1, target);
    else
        return __binarySearch2(arr, mid+1, r, target);
}

4. 变种算法 floor 和 ceil

(1)算法思想

以上实现的二分查找,在数组中存在重复值的情况下无法确定返回的唯一索引值,于是二分查找法的变种:floor 和 ceil

这里写图片描述

  • floor:是查找元素在数组中第一个索引位置;若数组中无此元素,则是查找元素值前一个索引位置。
  • ceil:是查找元素在数组中最后一个索引位置;若数组中无此元素,则是查找元素值后一个索引位置。

这里写图片描述

(2)代码实现

// 二分查找法, 在有序数组arr中, 查找target
// 如果找到target, 返回第一个target相应的索引index
// 如果没有找到target, 返回比target小的最大值相应的索引, 如果这个最大值有多个, 返回最大索引
// 如果这个target比整个数组的最小元素值还要小, 则不存在这个target的floor值, 返回-1
template<typename T>
int floor(T arr[], int n, T target){

    assert( n >= 0 );

    // 寻找比target小的最大索引
    int l = -1, r = n-1;
    while( l < r ){
        // 使用向上取整避免死循环
        int mid = l + (r-l+1)/2;
        if( arr[mid] >= target )
            r = mid - 1;
        else
            l = mid;
    }

    assert( l == r );

    // 如果该索引+1就是target本身, 该索引+1即为返回值
    if( l + 1 < n && arr[l+1] == target )
        return l + 1;

    // 否则, 该索引即为返回值
    return l;
}


// 二分查找法, 在有序数组arr中, 查找target
// 如果找到target, 返回最后一个target相应的索引index
// 如果没有找到target, 返回比target大的最小值相应的索引, 如果这个最小值有多个, 返回最小的索引
// 如果这个target比整个数组的最大元素值还要大, 则不存在这个target的ceil值, 返回整个数组元素个数n
template<typename T>
int ceil(T arr[], int n, T target){

    assert( n >= 0 );

    // 寻找比target大的最小索引值
    int l = 0, r = n;
    while( l < r ){
        // 使用普通的向下取整即可避免死循环
        int mid = l + (r-l)/2;
        if( arr[mid] <= target )
            l = mid + 1;
        else // arr[mid] > target
            r = mid;
    }

    assert( l == r );

    // 如果该索引-1就是target本身, 该索引+1即为返回值
    if( r - 1 >= 0 && arr[r-1] == target )
        return r-1;

    // 否则, 该索引即为返回值
    return r;
}



二. 二分搜索树

1. 优势

首先来了解二分搜索树的优势。

(1)查找表的实现 - 字典数据结构

查找表的实现,通常这种实现又被称为“字典数据结构”,都是以键值对形式形成了表,通过key来查找对应value。如果这些key值都是整型,那么可以使用数组实现,但是在实际运用中key值是比较复杂的,例如字典。因此需要实现一个“查找表”,最基础方式就是二分搜索树。

这里写图片描述

(2)时间复杂度比较

通过以上分析,其实普通数组和顺序数组也可以完成以上需求,但是操作起来消耗的时间却不尽人意。

这里写图片描述

(3)高效性

  • 不仅可查找数据,还可以高效地插入,删除数据之类的动态维护数据
  • 可以方便地回答很多数据之间的关系问题
    • min, max
    • floor, ceil
    • rank
    • select

2. 定义

(1)特征

  • 二分搜索树本质上是一棵二叉树。
  • 每个节点的键值大于左孩子
  • 每个节点的键值小于右孩子
  • 以左右孩子为根的子树仍为二分搜索树

这里写图片描述

注意:上篇博文中讲解的堆是一棵完全的二叉树,但对于二分搜索而言,并无此限制,例如下图。

这里写图片描述


3. 代码实现

在代码实现堆时,正是因为它是一棵完全的二叉树此特点,所以可使用数组进行实现,但是二分搜索树并无此特性,所以在实现上是设立key、value这种Node节点,节点之间的连续使用指针。

Node节点结构体包含:

  • Key key;
  • Value value;
  • Node *left; //左孩子节点指针
  • Node *right; //右孩子节点指针

私有成员变量:

  • Node *root; // 根节点
  • int count; // 节点个数

公有基本方法:

  • BST() // 构造函数, 默认构造一棵空二分搜索树
  • int size() // 返回二分搜索树的节点个数
  • bool isEmpty() // 返回二分搜索树是否为空

以下就是二分搜索树的基本结构,实现并不复杂,代码如下:

// 二分搜索树
template <typename Key, typename Value>
class BST{

private:
    // 二分搜索树中的节点为私有的结构体, 外界不需要了解二分搜索树节点的具体实现
    struct Node{
        Key key;
        Value value;
        Node *left;
        Node *right;

        Node(Key key, Value value){
            this->key = key;
            this->value = value;
            this->left = this->right = NULL;
        }
    };

    Node *root; // 根节点
    int count;  // 节点个数

public:
    // 构造函数, 默认构造一棵空二分搜索树
    BST(){
        root = NULL;
        count = 0;
    }
    ~BST(){
        // TODO: ~BST()
    }

    // 返回二分搜索树的节点个数
    int size(){
        return count;
    }

    // 返回二分搜索树是否为空
    bool isEmpty(){
        return count == 0;
    }
};

4. 插入新节点

算法思想

查看以下动画演示了解插入新节点的算法思想:(其插入过程充分利用了二分搜索树的特性)

例如待插入数据60,首先与根元素41比较,大于根元素,则与其右孩子再进行比较,大于58由于58无右孩子,则60为58的右孩子,过程结束。(注意其递归过程)

这里写图片描述

代码实现:insert函数

  • 判断node节点是否为空,为空则创建节点并将其返回( 判断递归到底的情况)。
  • 若不为空,则继续判断根元素的key值是否等于根元素的key值:
    • 若相等则直接更新value值即可。
    • 若不相等,则根据其大小比较在左孩子或右孩子部分继续递归直至找到合适位置为止。
public:
    // 向二分搜索树中插入一个新的(key, value)数据对
    void insert(Key key, Value value){
        root = insert(root, key, value);
    }

private:
    // 向以node为根的二分搜索树中, 插入节点(key, value), 使用递归算法
    // 返回插入新节点后的二分搜索树的根
    Node* insert(Node *node, Key key, Value value){

        if( node == NULL ){
            count ++;
            return new Node(key, value);
        }

        if( key == node->key )
            node->value = value;
        else if( key < node->key )
            node->left = insert( node->left , key, value);
        else    // key > node->key
            node->right = insert( node->right, key, value);

        return node;
    }
};

以上就是二分搜索树插入节点的算法,以递归的形式进行实现。同样,insert也可使用非递归实现,各位私下可尝试完成。


5. 二分搜索树的查找

其实在理解二分搜索树的插入过程后,其查找过程本质上是相同的,这里提供两个搭配使用的查找函数:

  • bool contain(Key key):查看二分搜索树中是否存在键key
  • Value* search(Key key):在二分搜索树中搜索键key所对应的值。如果这个值不存在, 则返回NULL。(注意:这里返回值使用Value* ,就是为了避免用户查找的值并不存在而出现异常)

直接查看代码:

public:
   // 查看二分搜索树中是否存在键key
    bool contain(Key key){
        return contain(root, key);
    }

    // 在二分搜索树中搜索键key所对应的值。如果这个值不存在, 则返回NULL
    Value* search(Key key){
        return search( root , key );
    }

private:
    // 查看以node为根的二分搜索树中是否包含键值为key的节点, 使用递归算法
    bool contain(Node* node, Key key){

        if( node == NULL )
            return false;

        if( key == node->key )
            return true;
        else if( key < node->key )
            return contain( node->left , key );
        else // key > node->key
            return contain( node->right , key );
    }

    // 在以node为根的二分搜索树中查找key所对应的value, 递归算法
    // 若value不存在, 则返回NULL
    Value* search(Node* node, Key key){

        if( node == NULL )
            return NULL;

        if( key == node->key )
            return &(node->value);
        else if( key < node->key )
            return search( node->left , key );
        else // key > node->key
            return search( node->right, key );
    }
};

6. 链表与二分搜索树 查找时间复杂度比较

将《圣经》中的内容存放在txt文件中,分别用以上实现的二分搜索树BST、顺序查找表SST(本质是一个链表)来统计文件中“god”的词频时间复杂度。

(注: 这个词频统计法相对简陋, 没有考虑很多文本处理中的特殊问题,在这里只做性能测试,具体的测试代码在github源码中查看)

结果展示

这里写图片描述

结论分析

以上的结果已经显而易见,“god”一词在《圣经》中出现了2301此,但是二分搜索树只需1.7秒就获取了结果,而链表却消耗了28秒,本质上的差别显而易见,体现出了二分搜索树的高效性。




三. 二分搜索树的遍历

接下来讲解二分搜索树的遍历过程,学习之后不论是其它的树、图结构,都会使用到遍历。

二分搜索树的前中后序遍历:

对于每个节点而言,可能会有左、右两个孩子,所以分成下图中3个点,每次递归过程中会经过这3个点

这里写图片描述

  • 前序遍历:先访问当前节点,再依次递归访问左右子树
  • 中序遍历:先递归访问左子树,再访问自身,再递归访问右子树
  • 后续遍历:先递归访问左右子树,再访问自身节点

1. 前序遍历

(1)算法思想

前序遍历:先访问当前节点,再依次递归访问左右子树。查看以下动画即可

这里写图片描述

其实在遍历过程中每个节点都访问了3次,对应着这3个小点,顺序为前-> 中 -> 后只有在“”点时才会打印该节点元素值。

最终打印结果:

这里写图片描述

(2)代码实现

public:
    // 二分搜索树的前序遍历
    void preOrder(){
        preOrder(root);
    }

private:
    // 对以node为根的二叉搜索树进行前序遍历, 递归算法
    void preOrder(Node* node){
        if( node != NULL ){
            cout<<node->key<<endl;
            preOrder(node->left);
            preOrder(node->right);
        }
    }

2. 中序遍历

(1)算法思想

中序遍历:先递归访问左子树,再访问自身,再递归访问右子树。

这里写图片描述

在遍历过程中每个节点都访问了3次,对应着这3个小点,顺序为前-> 中 -> 后只有在“”点时才会打印该节点元素值。

最终打印结果:

这里写图片描述

查看其打印结果,是按照从小到大的顺序进行打印的,所以在进行实际应用时,可使用二分搜索输的中序遍历将元素按照从小到大顺序输出。其原因与二分搜索树定义相关的!

(2)代码实现

public:
    // 二分搜索树的中序遍历
    void inOrder(){
        inOrder(root);
    }

private:
    // 对以node为根的二叉搜索树进行中序遍历, 递归算法
    void inOrder(Node* node){

        if( node != NULL ){
            inOrder(node->left);
            cout<<node->key<<endl;
            inOrder(node->right);
        }
    }

3. 后序遍历

(1)算法思想

后续遍历:先递归访问左右子树,再访问自身节点。

这里写图片描述

在遍历过程中每个节点都访问了3次,对应着这3个小点,顺序为前-> 中 -> 后只有在“”点时才会打印该节点元素值。

最终打印结果:

这里写图片描述

(2)代码实现

public:
    // 二分搜索树的后序遍历
    void postOrder(){
        postOrder(root);
    }

private:
    // 对以node为根的二叉搜索树进行后序遍历, 递归算法
    void postOrder(Node* node){

        if( node != NULL ){
            postOrder(node->left);
            postOrder(node->right);
            cout<<node->key<<endl;
        }
    }

以上所有深度优先遍历代码实现可分为3个步骤:

  • 递归左孩子
  • 递归右孩子
  • 打印自身

以上遍历只是交换了这3个步骤的执行顺序。


4. 释放空间

(1)析构函数思想

在第二大点中构造二分搜索树的基本结构时,并未具体实现析构函数~BST(),而在理解以上深度优先遍历思想后,可以由此实现:通过后序遍历来删除节点。先判断节点是否为空,若不为空,则先删除掉其左孩子,再删除掉右孩子,最后毫无顾虑了,删除掉自身。

(2)析构函数代码实现

public:
    // 析构函数, 释放二分搜索树的所有空间
    ~BST(){
        destroy( root );
    }

private:
    // 释放以node为根的二分搜索树的所有节点
    // 采用后续遍历的递归算法
    void destroy(Node* node){

        if( node != NULL ){
            destroy( node->left );
            destroy( node->right );

            delete node;
            count --;
        }
    }


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


以上是二分搜索树的部分内容,需要注意的是二分搜索树中最复杂的部分——删除节点,在下篇博文会进行讲解,涉及到的知识点如下:

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

若有错误,虚心指教~

相关推荐
©️2020 CSDN 皮肤主题: 书香水墨 设计师:CSDN官方博客 返回首页