算法学习——二叉搜索树

前言

之前几篇,主要讲了排序问题,同时也涉及到了一点数据结构,比如堆。这里我将介绍一下一个重要的数据结构树。
其实之前的堆其实已经说过,它属于完全二叉树。而维基百科中讲到,单纯的树有以下特点:

  1. 每个节点有零个或多个子节点;
  2. 没有父节点的节点称为根节点(树叶);
  3. 每一个非根节点有且只有一个父节点;
  4. 除了根节点外,每个子节点可以分为多个不相交的子树;
    这里我们为什么要使用二叉搜索树呢?因为它在查找、删除、查找时都有较好的性能,各种数据结构搜索方式对比:

对比

二分查找法

在说二分搜索树之前,我们先来看看与之思想几乎一样的二分查找法。

原理

在使用二分查找法之前,我们必须先对数组进行排序,这里可以看到算法其实是相辅相成的,很多时候一些算法的子过程也用到了其他的算法。
排序完成之后,我们取中间的那个数v与目标数进行比较,左边的部分就是小于v的,右边的就是大于v的,如果查找失败,那缩小范围继续取中间的值进行比较,直到查找结束。
二分查找

代码实现

这里我们就使用了循环的方法进行查找,特别需要注意的是mid的溢出问题。L,R可以保证是在int范围内,但是他们相加的结果并不一定在int范围内,这就会导致溢出。这里就使用了一个很巧妙的方法避免了溢出问题,先进行减法,再加上偏移量。L+(R-L)/2在范围内,结果上是和(L+R)/2一致的,但是前者有效地避免了溢出。

//找到target,返回相应的索引index
//如果没有找到target,返回-1
template<typename T>
int binarySearch(T arr[], int n, T target) {

    //在arr[L...R]之中查找
    int L = 0, R = n-1;
    while(L <= R) {
        //可能存在溢出bug
//        int mid = (L+R)/2;
        int mid = L + (R-L)/2;
        if(arr[mid] == target)
            return mid;

        if(target < arr[mid])
            //在arr[L...mid-1]之中查找target
            R = mid - 1;
        else
            //在arr[mid+1...R]之中查找
            L = mid + 1;
    }

    return -1;
}

当然我们还可以用递归的方法

//递归方法实现
template<typename T>
int __binarySearch(T arr[], int L, int R, T target) {

    if(L > R)
        return -1;

    //在arr[L...R]之中查找
    int mid = L + (R-L)/2;

    if(arr[mid] == target)
        return mid;
    else if(arr[mid] > target)
        return __binarySearch(arr, L, mid-1, target);
    else
        return __binarySearch(arr, mid+1, R, target);

}

template<typename T>
int binarySearch2(T arr[], int n, T target) {

    int result = __binarySearch(arr, 0, n-1, target);

    return result;
}

当然我们这里的查找可以看出是查找一个没有重复值的数组,如果有重复值,我们会去搜索它的floor和ceil,由于时间原因我暂时还没有实现,有兴趣大家可以去尝试实现一下。

二叉搜索树

原理

二叉搜索树是树的其中一种,与最大二叉堆不同,它不一定是完全二叉树,它的特点如下:

  1. 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
  2. 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
  3. 任意节点的左、右子树也分别为二叉查找树;
  4. 没有键值相等的节点。

如图所示:
这里写图片描述

代码实现

BST类

我们的所有方法应该都包含在一个BST类中,其中Key和Value是键值,他们可以是整数也可以是字符串等。

template <typename Key, typename Value>
class BST{
//内容稍后补充
};

节点定义

首先,我们需要定义树的节点,它应该有键值两个属性,同时还需要指向左右子树的指针。第一个构造函数就是普通节点的构造函数,第二个构造函数是复制一个节点,后面会有使用,这里先写出来,当然实际开发的过程中并不可能一步到位。。

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(Node* node) {
            this->key = node->key;
            this->value = node->value;
            this->left = node->left;
            this->right = node->right;
        }
    };

插入

根据定义来看,其实二叉搜索树本身就很适合使用递归来进行操作,因为它的任一子树本身都是二叉搜索树。那么我们的插入形成一棵二叉搜索树的过程也可以使用递归。
我们先从根节点插入,比较过后,小的话转到左子树,大的话转到右子树,不断递归完成操作。如图所示:
插入
代码如下:

public:
    void insert(Key key, Value value) {

        root = insert(root, key, value);
    }
private:
    //向以node为根的二叉搜索树中插入节点
    //返回插入节点之后二叉搜索树的根
    Node* insert(Node *node, Key key, Value value) {

        //递归到底条件
        if(node == NULL) {
            count++;
            return new Node(key, value);
        }

        if(key == node->key)
            //key相等执行修改操作
            node->value = value;
        else if(key < node->key)
            node->left = insert(node->left, key, value);
        else
            node->right = insert(node->right, key, value);

        return node;
    }

搜索

搜索同样是使用递归,但是返回值我们需要进行一定思考。如果我们返回一个Node那么等于是将我们的结构暴露了出来,同时在使用这个函数时,也有诸多不变,一个比较优秀的函数应该将自身的实现结构隐藏,返回一个易处理的数据类型。这里我们返回了一个Value型的指针,因为指针可以为空,如果不返回指针的话,我们需要对搜索失败的情况进行特殊处理。
这里也是使用了二叉搜索树的性质:左节点一定小于根节点,右节点一定大于根节点。如图:
搜索

public:
    /**
    返回值不适用Node,对外隐藏数据结构
    返回Value可能不存在
    */
    Value* search(Key key){
        return search(root, key);
    }

private:
    //搜索以node为根的二叉搜索树中key对应的value
    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
            return search(node->right, key);

    }

三种遍历

听说过二叉树,自然也就会听说过前序遍历中序遍历后序遍历了。
前序遍历的顺序:根->左->右;
中序遍历的顺序:左->根->右;
后序遍历的顺序:左->右->根。
这是一张中序遍历的图,中间的蓝点就是进行输出的时候,我们按照箭头的方向前进,遇到蓝色的点就输出,最后就完成了中序遍历。前序就是左边的点,后序就是右边的点了。这是一个快速判断输出结果的方式,但是我们实现的时候还是需要紧紧抓住定义。
中序遍历
代码实现如下:

public:
    //前序遍历
    void preOrder(){
        preOrder(root);
    }

    //中序遍历
    void inOrder(){
        inOrder(root);
    }

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

    //对以node为根的二叉搜索树进行中序遍历
    void inOrder(Node* node){
        if(node != NULL){
            inOrder(node->left);
            cout<<node->key<<endl;
            inOrder(node->right);
        }
    }

    //对以node为根的二叉搜索树进行后序遍历
    void postOrder(Node* node){

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

其实我们可以看到,代码真的没几行,只要紧紧抓住定义即可。

层序遍历

想必大家听说过了三种遍历,也一定听说过深度优先广度优先吧。刚刚那三个其实就是深度优先的实现,首先考虑向下搜索。这里我们介绍一个广度优先的遍历方式——层序遍历
层序遍历,顾名思义,就是一层一层地遍历下去。我们实现层序遍历的方式需要一种数据结构,它就是队列
我们首先放入根节点,然后队首出队,这是放入队首左右节点,这时左节点成为队首,不断循环完成操作。
层序遍历
代码如下:

    //层序遍历
    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);
        }
    }

删除

删除是二分搜索树的难点,因为你在删除时,还必须保持二叉搜索树的数据结构。

查找最大值与最小值

根据二叉搜索树的定义,我们可以很快就能发现,最大值是最右边的节点,最小值就是最左边的节点。因此,我们只要不断递归即可实现。
代码实现:

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;
    }

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);
    }

删除最大最小值

删除最大最小值我们首先要找到最大最小值,找到之后,我们就可以进行删除操作了。
首先是删除最小值,最小值节点根据定义是不可能存在左节点的,因为没有更小的值了。如果说没有右节点,那最简单,直接删除就可以;如果有右节点,只需要将右节点替换原来的节点即可,因为右节点本身满足二叉搜索树,而它又肯定比自己父节点的父节点要小,所以替换原来的父节点后,整个树依然满足二叉搜索树性质。
这里我当时也迷了一会,但是重新看insert函数的时候,就可以知道为什么是这样了。
最小值
同理,删除最大值时,也是考虑两种情况:一种是没有左子树,直接删除;另一种有左子树,那么将左子树上移即可。
最大值
代码实现:

public:
    //从二叉树中删除最小值所在的节点
    void removeMin(){
        if(root)
            root = removeMin(root);
    }

    //从二叉树中删除最大值所在的节点
    void removeMax(){
        if(root)
            root = removeMax(root);
    }

private:
    //删除以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->right;
            delete node;
            count--;

            return leftNode;
        }

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

删除任意节点

我们删除最大最小值的时候,只需要移动节点即可,但是当我们需要删除任意节点时,情况就复杂了很多,我们必须找到一个节点,能替换删除的节点同时保持二叉搜索树的性质。
同样我们采用递归的方法。首先进行比较,先找到位置所在,左子树是比根小的值,右子树是比根大的值。接着,找到之后,有三种情况:

  1. 删除没有左子树的值
  2. 删除没有右子树的值
  3. 删除拥有左右子树的值

其中,前两种删除方法和之前的很类似,直接移动左右子树即可。第三种的话,我们可以查找它的后继节点。
后继节点
可以看到如果我们要删除58,那么它的右子树的最小值替换之后,整个二叉搜索树依然能保持定义。因为右子树的任意一个值必定大于左子树的任意一个值,而59又满足比60小的定义,因此可以进行一次移动。
完成移动
代码如下:

public//从二叉树中删除键值为key的节点
    void remove(Key key){
        remove(root, key);
    }
private:
    //删除掉node为根二分搜索树中key的节点
    //返回删除节点后二分搜索树的根
    Node* remove(Node* node, Key key){

        //寻找
        //不存在
        if(node == NULL)
            return NULL;

        if(key < node->key){
            node->left = remove(node->left, key);
            return node;
        }
        else if(key > node->key){
            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;
        }
    }

第二种方式是找前驱节点

    //if node->left && node->right != NULL
    Node* processor = New Node(maximum(node->left));
    count++;

    processor->left = removeMax(node->left);
    processor->right = node->right;

    delete node;
    count--;

    return processor;

后记

当然我们的二叉搜索树也并非是完美的,一是尚无法对重复的元素进行操作,二是当数组有序时,会退化成链表。
对于第一种情况,我们可以给Node添加新的属性count进行统计;第二种情况,有一种经典的实现叫做红黑树,希望之后能同大家分享。


各项定义部分引用自维基百科
图片引用百度图片
代码实现参照liuyubobobo慕课网教程

猜你喜欢

转载自blog.csdn.net/blueblueskyz/article/details/79432602