数据结构之二叉搜索树

概念

二叉搜索树是二叉数的一种特例,二叉树又是树的其中一种,树又是图的一种特例。二叉搜索树作为特例中的特例,结构最为简单,编码也最容易理解,作为学习树和图的起点最为合适。
首先树是由节点和边组成的,节点一般用来储存数据,边用来表示节点之间的联系,Java中常常用引用来表示边。
树有一些概念,如:根、父节点、子节点、叶结点、子树、路径等,这些概念通过画图来理解比较容易。


二叉搜索树.png

此图中的父节点、子节点以及子树的概念都是相对于标记节点40的
二叉树是指树中每个节点最多只能有两个子节点,如上图所示
而二叉搜索树是指:一个节点的左子节点的关键值小于这个节点的关键值,右子节点的关键值大于或等于这个节点的关键值的二叉树,如上图所示。

二叉搜索树的方法与实现

搞清楚二叉搜索树的概念之后,我们来考虑它的方法和实现。

一颗二叉搜索树的组成包括:节点和引用
首先来实现节点,节点应该封装了需要储存的数据,数据类型可以是数字,字符串等,也可以是一个对象的引用,方便起见,我们这里就存储一个整型数字iData。
一个节点还需要持有其子节点的引用,在二叉搜索树中就分为:左子节点(leftChild)和右子节点(rightChild),叶子节点的子节点引用指向null。
以下代码实现了一个简单的节点类Node

public class Node {

    private int data;
    public Node leftChild;
    public Node rightChild;

    //constructor
    public Node(int data) {
        this.data = data;
    }

    public int getData() {
        return data;
    }

    public void displayNode() {
        System.out.print("{" + data + "}");
    }
}

一颗二叉搜索树的方法应该有:查找、插入、遍历、删除
二叉搜索树的基点是根节点,从根节点来展开以上方法,所以一个二叉搜索树的对象需要持有根节点的引用root,在还没有节点插入之前,root的初始值为null,如下代码所示:

public class BinaryTree {

    //reference to root
    private Node root;

    //constructor
    public BinaryTree() {
        root = null;
    }
}

由于二叉搜索树的特性(即当前节点的值大于其左子节点的值,小于其右子节点的值),使得其在查找、插入和遍历的实现上较为容易,而删除则比较麻烦,因为删除一个节点之后需要重新构建二叉搜索树使其满足特性,我们逐个来分析。

查找

传入一个整型类型的值,从根节点开始比较,根据比较结果去搜索其左子树或是右子树,逻辑较为简单,实现代码:

public Node find(int key) {
    Node current = root;
    while(current.getData() != key) {
        if (key < current.getData()) {
            current = current.leftChild;
        } else {
            current = current.rightChild;
        }
        if (current == null) {
            return null;
        }
    }
    return current;
}

需要注意的是退出循环的条件,当current指向null的时候表明已比较过叶子节点,即没有找到key。

插入

插入和查找的逻辑几乎一样,需要注意的是插入的节点如何算作树的一部分:是其父节点的子节点引用指向该插入的节点,所以除current外,还需要一个parent引用
下面来看insert的实现:

public void insert(int key) {
    Node newNode = new Node(key);
    if (root == null) {
        root = newNode;
    } else {
        Node current = root;
        Node parent;
        while(true) {
            parent = current;
            if (key < current.getData()) {
                current = current.leftChild;
                if (current == null) {
                    parent.leftChild = newNode;
                    return;
                }
            } else {
                current = current.rightChild;
                if (current == null) {
                    parent.rightChild = newNode;
                    return;
                }
            }
        }
    }
}

在做插入判断的时候,需要对左右两个子节点的分支做独立判断,为简化代码,可以设置一个标志变量isLeftChild(boolean)来合并两个分支的插入判断,在删除方法中,逻辑较为复杂,我们就可以使用这种方法。

遍历

遍历树有三种简单的方法:前序遍历(preOrder)、中序遍历(inOrder)和后序遍历(postOrder)
二叉搜索树因其结构特性,可以使用递归的方法来实现遍历,一般来说二叉搜索树的中序遍历比较有现实意义,因为可以得到有序的数据序列。
中序遍历的实现方法为:
1.对当前节点递归的调用自身来遍历其左节点
2.访问这个节点
3.调用自身来遍历其右节点
以下是实现代码:

private void inOrder(Node localRoot) {
    while(localRoot != null) {
        inOrder(localRoot.leftChild);
        System.out.print(localRoot.getData() + " ");
        inOrder(localRoot.rightChild);
    }
}

前序遍历和后序遍历相比于中序遍历,其实现上的差异就在于调用的顺序不同:前序遍历是先访问当前节点再遍历左节点最后遍历右节点,后序遍历则是先遍历左节点再遍历右节点最后访问当前节点,以下是其实现代码:

private void preOrder(Node localRoot) {
    if (localRoot != null) {
        System.out.print(localRoot.getData() + " ");
        preOrder(localRoot.leftChild);
        preOrder(localRoot.rightChild);
    }
}

private void postOrder(Node localRoot) {
    if (localRoot != null) {
        postOrder(localRoot.leftChild);
        postOrder(localRoot.rightChild);
        System.out.print(localRoot.getData() + " ");
    }
}
删除

删除方法是二叉搜索树中最难实现的方法,其逻辑复杂在于:删除一个节点之后,为了填补空洞,需要重新构建一个二叉搜索树,且删除节点又分几种情况:删除节点无子节点、删除节点只有一个子节点、删除节点有两个子节点等,接下来结合代码逐一分析

首先删除方法中同样需要持有两个引用current和parent,一个指向当前节点,一个指向当前节点的父节点,为了减少代码量,设置了一个布尔值isLeftChild,来标注current的是否为parent的左子节点。
Node current = root;
Node parent = root;
boolean isLeftChild = true;

接下来根据传入的key,找到需要删除的节点,这一步的实现代码类似于查找的代码:

while(key != current.getData()) {
    parent = current;
    if (key < current.getData()) {
        isLeftChild = true;
        current = current.leftChild;
    } else {
        isLeftChild = false;
        current = current.rightChild;
    }
    if (current == null) {
        return false;
    }
}

此时的current已经指向要删除的节点了,那么就要把待删除的节点分几种情况来讨论:
1.无子节点
2.有一个子节点
3.有两个子节点

当无子节点时,即待删除的节点为叶子节点,那么可以直接删除,对树的结构没有什么影响

if (current.leftChild == null && current.rightChild == null) {
    if (current == root) {      //需要考虑的特例
        root = null;
    } else {
        if (isLeftChild) {
            parent.leftChild = null;
        } else {
            parent.rightChild = null;
        }
    }
}

当仅有一个子节点时,需要进行左右子节点的分别判断处理

else if (current.leftChild == null) {
    if (current == root) {
        root = root.rightChild;
    } else {
        if (isLeftChild) {
            parent.leftChild = current.rightChild;
        } else {
            parent.rightChild = current.rightChild;
        }
    }
}
else if (current.rightChild == null) {
    if (current == root) {
        root = root.leftChild;
    } else {
        if (isLeftChild) {
            parent.leftChild = current.leftChild;
        } else {
            parent.rightChild = current.leftChild;
        }
    }
}

代码中比较不容易理解的地方就是四个比较长的赋值语句,其实就是删除的过程本身:将父节点的子节点引用指向待删除节点的子节点,自己动手画图会比较容易理解。

下面讨论最复杂的情况,待删除的节点有两个子节点,且可能有子树。
这种情况,待删除的节点处于树比较中间的位置,故需要找到一个替代节点来填补这个空洞,重构二叉搜索树的结构,这个替代节点也叫删除节点的后继,下面画图说明如何寻找后继节点

寻找后继结点.png

看以看出寻找后继节点的窍门在于:后继节点为删除节点右子树的最左子节点
转化为算法描述即为:程序首先找到删除节点的右子节点,如果其存在左子树则寻找其左子节点的左子节点的左子节点......直到找到最后一个左子节点(它可以有右子节点,但不可以有左子节点,否则它就不是最后一个左子节点),如果这个右子节点不存在左子节点,那么后继节点就是这个右子节点。

这个描述比较拗口,结合下图理解会比较容易


寻找后继节点过程示意图.png

下面给出寻找后继节点的代码:

private Node getReplace(Node delNode) {
    Node replace = delNode;
    Node replaceParent = delNode;
    Node current = delNode.rightChild;
    while(current != null) {     //三个指针都向最左子节点移动
        replaceParent = replace;
        replace = current;
        current = current.leftChild;
    }
    //找到后继节点后,可能是删除节点的右子节点,则右子节点为根的子树直接上移替代删除节点即可
    //如果后继节点为最左子节点,则需要处理后继节点的右子树,并用后继节点替代删除节点
    if (replace != delNode.rightChild) {
        replaceParent.leftChild = replace.rightChild;
        replace.rightChild = delNode.rightChild;
    }
    
    return replace;
}

找到后继节点后,继续刚才的逻辑,接下来需要将删除节点的父节点引用指向后继节点,且把删除节点的左子树给后继节点拼接上,代码如下:

else {
    Node replace = getReplace(current);
    if (current == root) {   //需要考虑的特殊情况
        root = replace;
    } else {    //切换parent引用
        if (isLeftChild) {
            parent.leftChild = replace;
        } else {
            parent.rightChild = replace;
        }
    }
    //拼接左子树
    replace.leftChild = current.leftChild;
}

至此,删除方法的所有情况均已考虑在内,还是挺难理解的,最好可以自己画图分析一下,以下给出完整的删除方法的完整代码

public boolean delete(int key) {
    Node current = root;
    Node parent = root;
    boolean isLeftChild = true;
    while(key != current.getData()) {
        parent = current;
        if (key < current.getData()) {
            isLeftChild = true;
            current = current.leftChild;
        } else {
            isLeftChild = false;
            current = current.rightChild;
        }
        if (current == null) {
            return false;
        }
    }
    if (current.leftChild == null && current.rightChild == null) {
        if (current == root) {      //需要考虑的特例
            root = null;
        } else {
            if (isLeftChild) {
                parent.leftChild = null;
            } else {
                parent.rightChild = null;
            }
        }
    }
    else if (current.leftChild == null) {
        if (current == root) {
            root = root.rightChild;
        } else {
            if (isLeftChild) {
                parent.leftChild = current.rightChild;
            } else {
                parent.rightChild = current.rightChild;
            }
        }
    }
    else if (current.rightChild == null) {
        if (current == root) {
            root = root.leftChild;
        } else {
            if (isLeftChild) {
                parent.leftChild = current.leftChild;
            } else {
                parent.rightChild = current.leftChild;
            }
        }
    }
    else {
        Node replace = getReplace(current);
        if (current == root) {   //需要考虑的特殊情况
            root = replace;
        } else {    //切换parent引用
            if (isLeftChild) {
                parent.leftChild = replace;
            } else {
                parent.rightChild = replace;
            }
        }
        //拼接左子树
        replace.leftChild = current.leftChild;
    }
    return true;
}
private Node getReplace(Node delNode) {
    Node replace = delNode;
    Node replaceParent = delNode;
    Node current = delNode.rightChild;
    while(current != null) {     //三个指针都向最左子节点移动
        replaceParent = replace;
        replace = current;
        current = current.leftChild;
    }
    //找到后继节点后,可能是删除节点的右子节点,则右子节点为根的子树直接上移替代删除节点即可
    //如果后继节点为最左子节点,则需要处理后继节点的右子树,并用后继节点替代删除节点
    if (replace != delNode.rightChild) {
        replaceParent.leftChild = replace.rightChild;
        replace.rightChild = delNode.rightChild;
    }
    return replace;
}

二叉搜索树的效率

二叉搜索树的时间复杂度与其树高有关,在查找、插入和删除代码中可以观察到,比较的次数和树的层数正相关,假设树的层数为L,节点数位N,对于一颗满二叉搜索树,存在关系:2^(L -1) = N,那么L=log2N+1(2为底数),
所以一般来说时间复杂度为O(logN),不满的树的平均查找时间比满树要短,因其在层数较低的比较次数少于满树。

考虑特殊情况,如果一颗二叉搜索树极不平衡,例如插入的是一串升序序列,那么新插入的节点都是上个节点的右子节点,将会导致二叉搜索树变成一个链表,时间复杂度退化为O(N),所以对于一颗二叉树结构来说,平衡是一个重要的特性,红黑树的诞生就是为了解决树的不平衡而导致的效率下降问题。

参考文章

《Data Structures & Algorithms in Java》 Robert Lafore著

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容