个人感觉,BST(二叉查找树)应该是众多常见树的爸爸,而不是弟弟,尽管相比较而言,它比较简单。


二叉树基础

理论定义,代码定义,满,完全等定义

不同于线性结构,树结构用于存储的话,通常操作效率更高。就拿现在说的二叉搜索树(排序树)来说,如果每次操作之后会让剩余的数据集减少一半,这不是太美妙了么?(当然不当的运用树结构存储,也会导致从 O(logN) 退化到 O(n))。

  • 值得说明,O(logN) 其实并不准确,准确来说应该说 O(树的高度)

定义&性质&行话

树里面常见的二叉树: BST, AVL,特殊的AVL(比如2-3树,即红黑树)。

不常见的有(包括多叉树): 线段树,Trie。(但我们说的不常见,不见得真的不常见,可能内核或者框架里面有用到,而写应用的没有用到;所以常见或者不常见是没有一个确定概率基准的,个人把一般写应用的标准定义为基准)

二叉树就是一个最多: 一个爸爸,俩孩子 的树。

这样说,不形象,那形象点儿,直接上代码:

class Node {
E e;
Node left;
Node right;
}

当然,理论教科书上肯定不会这么傻帽的直接告诉你具体情况,它要绕一下,先来一个递归定义,把你绕晕,有图有真相:

然后一个节点也是树,空(null) 也是树。

这里有一些行话,包括上面的认为 logN 就是树高(在一般性的时间复杂度分析时),解释:

  • 深度为 n 的二叉树,每层最多有 2^(n-1) 个节点
  • 深度为 n 的二叉树,总共元素,最多有 2^n -1 个节点

(深度从 1 开始,从上往下看)(自己画一下图,脑海中想一下就知道了)

其他行话,满二叉树,完全二叉树,尤其完全二叉树这个概念,后续树结构中有很重要的意义。

  • 满的很好理解吧,图上理解,就是所有父亲都有俩孩子;数据上就是元素总共为 2^n-1
  • 完全二叉树: 如果你从上到下,从左到右给树的节点编号,那么树应该是你看到的方向排布。

设二叉树的深度为h,除第 h 层(最后一层)外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树。

个人经验,直接看最后一层是否靠左

BST基础

BST 说白了也是一种查找树,只是它的存储是有序的。

  • (删除的时候要调整,就知道维护的代价了)

存入的值要么是可比较的,要么不能自身提供比较方法时,要主动传入比较器(Comparator)

也就是说,任意一个节点(它作为根节点看待),它的左子树的值都比它要小,右子树的值都比它大。(正是因为其存储的时候就有序,所以取的时候也高效)

哦,BST 和满二叉树没啥关系。


BST实现

基本框架

先设置好节点信息,大概如下:

package bst;

//二分搜索树
public class BST<E extends Comparable<E>> { //声明节点类
private class Node {
public E e;
public Node left, right; public Node(E e) {
this.e = e;
left = null;
right = null;
}
} //成员变量
private Node root;
private int size; //构造函数
public BST() {
root = null;
size = 0;
} public int getSize() {
return size;
} public boolean isEmpty() {
return size == 0;
} }

清晰,简介,然后来补充增删查改,遍历等方法。

先做一个规定,对于重复元素的处理是: do nothing

(它已经在树上了,就不要动了;或者你可以给 Node 增加一个属性 count,专门记录)

增加操作

想象一下,放一个元素到树上,从根节点开始比较,以后的比较也是根据当前树的根元素,从而决定放在左子树,还是右子树上。(上面已经说了,相等的元素,什么也不干)

上面的分析其实已经很清楚了,那么下面考虑一下写法:

  • 递归怎么写: 每次切换当前 root 元素,即要搜索的子树

    • 终结条件(base condition): 找到了相关的位置(null),然后放入元素,返回以该节点为根的子树给上级树,的左/右孩子接收
    • 子树不为空,且元素值不等,说明不是终极条件,那接着切换子树找啊
  • 循环怎么写: 思想类似上面,不断下探的过程改成while循环的解法,即不断递推替换 currNode 为其左或者右子树的根节点(就是孩子啦)
    • 技巧是,在寻找合适位置时,不仅需要记录 curr,还要记录 parent
    • 并且实际插入的时候,还是以 parent,即子树的根为合适位置的依据(这根递归做法不同)

一般教科书上才会介绍循环的写法,大致如下: 《算法-C描述》

OK,我们还是用 递归去写吧,不是偷懒,而是递归更易于宏观理解啊傻

递归的写法: 不断的切换每层调用的 root 节点的指向 (相当于切换到子树)

大致写法如下: (注意柯里化包裹 -- 柯里化参考阮一峰的博客JS语言部分)

  • 上面已经说了优化过的递归终止条件,但是也可以像下面这样没有优化(即还没有判断到底就开始分情况返回) --- 作为思考的中间结果吧 --- 优化的结果看最后面的实现。
    //向二分搜索树中添加元素
private void add(E e) {
if(root == null) {
root = new Node(e);
size++;
} else {
//切换当前 root 节点进行递归调用
add(e, root); //第一次调用时已经确定 root != null 了
}
} private void add(E e, Node node) {
//这里的 node 相当于一个子树的 root //复杂的递归终止条件
if(e.equals(node.e)) {
//已经存在了,do nothing
return;
} else if(e.compareTo(node.e)>0 && node.right == null) {
//不存在子树的情况
//右子树为空,直接插入
node.right = new Node(e);
size++;
return;
} else if(e.compareTo(node.e)<0 && node.left == null) {
node.left = new Node(e);
size++;
return;
} //一般的情况,即存在左或者右子树的情况
//此时已经判断 right 或者 left 不为 null 了
if(e.compareTo(node.e)>0) {
//应该在右子树上找位置
add(e, node.right); } else {
//在左子树上找位置
add(e, node.left);
}
}

但是终止条件可以写的更加简单一点,即一直找到空位置再说插入的事儿,而不是分情况判断时就作为终止条件:

  • 找到那个空位置,就在那个位置插入

此时,可以看到,递归函数 add 也需要修改成有返回值了,然后让这个返回值和原BST挂接。(返回以当前节点为根的子树,挂接给上级才对;思想就是: 上级只关心完整的结果!)

    //返回插入新节点后BST的根 (每层调用都是如此)
private Node add(E e, Node root) {
if (root == null) {
size++;
return new Node(e);
}
if (e.compareTo(root.e) > 0) {
//放在右子树
//因为add 函数返回的就是 插入新节点后BST的根
root.right = add(e, root.right); } else if (e.compareTo(root.e) < 0) {
root.left = add(e, root.left);
}
//相等的情况?抱歉,啥也做 return root;
}
  • 这里的症结就在于,给当前root的子树(不管左右哪一个),插入完成后,都会对当前 root 的左或者右孩子进行重新赋值(后半句是,所以才不关心左右子树空不空)

而递归代码中已经包含了 root == null 的情况,即空BST的情况,所以主调函数可以直接调用 private 的 add,而不必再判断 root 为 null:

    //client 调用
//向二分搜索树中添加元素
private void add(E e) {
root = add(e, root);
}

查询操作

这就相当于二分查找,不断的折半 — 这里判断是否存在即可

也可以分为用递归和非递归的写法,然而递归的写法会简单很多,能写递归也能写循环。

递归的写法

  • 不断的更换子树 (以根为子树的依据)
    //查询元素是否存在
public boolean contains(E e) {
return contains(root, e);
} //以 root 为根的BST中是否存在 e
private boolean contains(Node root, E e) {
if(root == null) {
return false;
}
if (e.compareTo(root.e) > 0) {
//去查右子树
return contains(root.right, e);
} else if (e.compareTo(root.e) < 0) {
return contains(root.left, e);
}
//return root.e.compareTo(e) == 0;
return true;
}

最后的root.e.compareTo(e) 没有必要,因为除了 > 和 < 剩下的就是 == 的情况了。

遍历操作(递归)

遍历相对来说还是比较容易的,这里先说深度优先

深度优先: 先序遍历,中序遍历,后序遍历;这些都是相对于根元素来说的。

先根遍历 来举个例子:

    public void preOrder() {
preOrder(root);
} private void preOrder(Node root) {
if(root == null) {
return;
}
System.out.println(root.e);
preOrder(root.left);
preOrder(root.right);
}

到这里可以简单测试一下:

    public static void main(String[] args) {
BST<Integer> bst = new BST<>();
int[] nums = {5, 3, 6, 8, 4, 2};
for(int num: nums){
bst.add(num);
}
bst.preOrder(); //5 3 2 4 6 8
}

其他的遍历方式,其实就非常容易改写了。

例如 中根遍历:

    //中序遍历
public void inOrder() {
inOrder(root);
} private void inOrder(Node root) {
if (root == null) {
return;
} inOrder(root.left);
System.out.println(root.e);
inOrder(root.right);
} //测试输出: 2 3 4 5 6 8 (相当于一个排序结果)

从输出的结果可以看到一个性质,BST的中序遍历结果是一个排序结果

例如 后序遍历:

    //后序遍历
public void postOrder() {
postOrder(root);
} private void postOrder(Node root) {
if (root == null) {
return;
} postOrder(root.left);
postOrder(root.right);
System.out.println(root.e);
}

先把孩子处理好,再来弄自己的事儿。。。可以联系到内存释放,先释放子窗口的,最后在关闭 main。

遍历操作(非递归)

这里用变量的迭代器换也能写,不过我见过的比较高明的手段,用栈来写。

用栈记录后面需要操作(当前还不需要执行),当前处理的则出栈(这里运用上,有一些技巧),我直接说了:

  • 每次都出栈当前子树根元素(打印操作),然后把它的左右子节点反序放入栈中(反序是因为栈是后入先出的);
  • 什么时候结束,树遍历完毕的时候;即栈里没有内容了

可以看下代码实现:


//先序遍历,非递归写法
public void preOrderNR() {
Stack<Node> stack = new Stack<>();
stack.push(root); //开始之前的准备,真正的根入栈
while (!stack.isEmpty()) {
Node curr = stack.pop(); //出栈当前的根元素
System.out.println(curr.e);
if (curr.right != null) {
stack.push(curr.right);
}
if (curr.left != null) {
stack.push(curr.left);
}
}
}

开始之前,根元素入栈,保证栈里面有内容,然后第一次检查栈,出栈一个元素,即真正的根元素,先打印它,接着其左右孩子反序入栈。下次循环,后入栈的左子树根出栈,进行左边子树的整个操作,全部完毕后才轮到右子树(的根出栈,然后完成类似结果)。

举一个形象的例子:

16 出栈后,此时压入 16 的右孩子 22,然后压入左孩子 13。(遍历的反序)

中序遍历,后序遍历的非递归实现?麻烦自己写一下,貌似没有太多实际参考。

层序遍历

就是广度优先啦,这个概念貌似应该用于图,但树也可以用到。

广度优先觉得更应该用于决策,因为它会避免你一条路走到黑。(不至于把一棵子树全部找完,然后发现没找到,才去找另一个子树。。。)

这个我比较熟悉,一般需要借助队列,写非递归实现:

  • 每次出队一个元素(当前的根节点)时,把其孩子也先后放入队列中(等待后续遍历)
  • 从结果来看,就是层序遍历了 (可以想象一下文件夹的遍历,把当前文件夹的子文件夹放入队列的尾部)

在脑海中可以想象一下,现实司令级别的班子先过,每次检查到一个司令(出队),就把他手下的军长排在队列的尾部(即所有司令的后部),这样一直不断,知道司令外部出去,检查第一个军长,同理把他手下的师长全部入队(即所有军长的后面)...直到队列为空,全部遍历完毕。

直接写代码:

    //广度优先-层序遍历
public void levelOrder() {
Queue<Node> queue = new LinkedList<>(); //util linkedlist 实现了 queue 接口
queue.add(root);
while((!queue.isEmpty()) {
Node curr = queue.remove();
System.out.println(curr.e);
if(curr.left != null) {
queue.add(curr.left);
}
if(curr.right != null) {
queue.add(curr.right);
}
}
}

忘了说了,层序遍历,先左后右

在图中的遍历还需要记录某个节点是否访问过,因为二叉树的爸爸只有一个,但是图中节点的前驱可能有多个的,为避免重复遍历,所以必须标记一下是否访问过。

删除操作(重点)

整个 BST 的难点就在这里,想也知道啊,我删了一个元素,那么以这个元素为根的树怎么调整才能与当前元素的上级挂接(嫁接)好呢?

(复杂可能在于,需要重新排序树)

题外话:

我自个儿也是琢磨了一会儿,然后发现对付复杂,**先从特例上找规律,然后推广到一般**。

特例的规律如若不能推广到一般,那么就按照第二种思路: 分分合合。
* 分: 详细分析情况,不漏掉情况(暴力穷举)
* 合: 这些具体的,分情况的方案能否合并,上升到一个层级规律 这样的好处,即便不能合,那么分情况的穷举(暴力解法),我们也能保底。(解决的不优雅,但按性质划分还是属于已解决的分类,有政治优势)

直接说结论,这里采用的是(找到元素)替换(覆盖)要被删除的元素

找一个怎么样的节点去替换啊? 找一个最接近被删除元素大小的元素替换

  • 此时其实挪动和改变最少,且满足删除后该树仍旧是一棵BST的要求

最接近的元素: 要删除元素的左子树上找最大,要删除元素的右子树上找最小。

先看看一棵子树上怎么找最大最小:(铺垫)

  • 查找最小值: 一直寻找左孩子,直到最左边的一个节点没有左孩子(null)。
  • 查找最大值: 一直寻找右孩子,直到最右边的一个节点没有右孩子(null)。

大致实现如下:

    //获取树的最小值 (递归写法)
public E getMin() {
if(isEmpty()) {
throw new IllegalArgumentException("Tree is Empty");
}
return getMin(root).e;//调用递归的写法
} private Node getMin(Node node) {
if(node.left == null) {
return node;
}
return getMin(node.left);
} //树的最大值 (非递归写法)
public E getMax() {
if(isEmpty()) {
throw new IllegalArgumentException("Tree is Empty");
}
Node curr = root;
while(curr.right != null) {
curr = curr.right;
}
return curr.e;
}

这里是拿到值,所以简单,但是删除的话,处理的是 Node,而不仅仅是值。

所以拿到最小元素的节点(当然顺带也要删除这个值),以及拿到最大元素的节点(顺带也要删除这个值),这两个值一定是最接近当前元素的,用它们中的一个,来覆盖当前被删除的元素的位置即可。

找到并删除(当前元素相关的)最小元素:

  • 最小元素: 向左找没有左节点的最终节点

    • 该节点没有孩子,(左子树一定为空了),直接删除 —— 叶子节点的情况
    • 该节点有右子树,直接返回该右节点 (作为子树的根,进行下一次循环或者递归起点) —— 非叶子节点
    • 维护 size 的大小

代码实现:

    //删除最小值 (递归写法)
public E removeMin() {
E ret = getMin(); //不需要 isEmpty判断了
root = removeMin(root); //操作完毕返回新树的根
return ret;
} //删除以 node 为根的子树的最小节点;返回该子树的根
private Node removeMin(Node node){
//base 情况
if(node.left == null) {
//当前子树的左子树为空(不管右子树如何),说明当前节点最小
//当然,如果有右子树,要把右子树嫁接到父节点(返回右子树的根给上级即可) //删除同时还要把当前节点的孩子置空
Node rightNode = node.right; //当前节点的左子树可能为空
node.right = null; //(当前节点的左子树已经为空了)
size--;
return rightNode;//返回给上级(具体说,上级的左子树)
} //一般情况 (以 node 为根,还有左子树)
node.left = removeMin(node.left); //递归过程返回调整后的子树(根节点)
return node; //每次都返回该子树删除最小值之后的结果
}

找到并删除(当前元素相关的)最大元素:

此过程类似于删除最小元素,递归操作时,每次都返回删除该node为根的子树的根节点,让上级节点的右节点接收。(末尾节点(右子树一定空)如果没有子节点,即左节点也为空,那么 base 就返回 null,否则把左子树嫁接到上级,具体说就是上级节点的右子树)

代码实现:

    //删除最大值 (递归写法)
public E removeMax() {
E ret = getMax();
root = removeMax(root);
return ret;
} //返回删除最大值后的根节点(子树)给上级节点
private Node removeMax(Node root) {
//Base 情况(末尾节点)
if(root.right == null) { //没右子树了
//判断其是否还有左子树,然后把左子树返回给上级节点(的右子树接收)
//同上,不必判断,因为 root.left 即便为 null 也包含在这种情况中
Node leftNode = root.left;
root.left = null;
size--;
return leftNode;
}
root.right = removeMax(root.right);//有右子树那就在右子树上找
return root;
}

从上面删除最大,最小的逻辑,可以推广到一般情况,具体来说,举个例子:

  • 删除 58 很容易,直接把 60 这个节点返回给右子树 (因为 41 为根的这棵树已经有左子树)
  • 删除 22 则情况至少有两种(左右都有节点),一种 15 上位(作为 41 的左子树),然后 33 接在 15 的右子树上,另外一种则是 33 上位,然后 15 接在 33 的左子树上
    • 这里 22 的子树比较特殊(只有半边子树),所以可以很容易看出两种情况
    • 也就是说,可以在左边找前驱,也可以在右边找后继
  • 叶子节点更简单,不必单独拿出来,直接返回给上级一个 null 即可作为子树即可

若某节点的子树都是满的,则不那么容易,以后继为例,讲一下思路:

其实就是在右子树上找一个最节点最接近当前值的节点,替代当前要删除的节点。即 min(d->right)。换句话说,右子树中找到最小元素(其实是删除掉这个元素),然后在 d 的位置填补这个元素,图例如下:

上面把 s 的一切设置好(left, right),此时 s 就是整个调整完毕的子树根,把它返回给上级树的右子树即可。

这里已经可以看出,结论如下:

  • 但凡有一边子树是空的,就是特殊情况,方便处理(下面代码也会证明这一点)
  • 整整困难的是两边都是满的,此时需要 找前驱或者后继,替换 这种思路(至于用前驱还是后继,随意; 因为都能保证之后的树是一棵BST)

那么代码实现: (参考删除最大最小的逻辑,推到删除的一般逻辑,以后继为例)

    /*找后继的实现*/
//用户指定删除某个值 e 的节点
public void remove(E e) {
root = remove(root, e); //每次递归都是返回操作后的子树(根节点)
} /*
删除以 root 为根的子树中值为 e 的节点
返回操作完毕后子树的根
*/
private Node remove(Node root, E e) {
//先要找到这个元素 //base 情况 (前一次递归调用返回的是 null 的情况)
if (root == null) {
return null; //这个空值最终会作为上级树的孩子
} //(分情况在子树上找)
if (e.compareTo(root.e) > 0) {
//在右子树上找
root.right = remove(root.right, e);
} else if (e.compareTo(root.e) < 0) {
//在左子树上找
root.left = remove(root.left, e);
} else {
// 找到的情况 e.compareTo(root.e) == 0
//从其左右子树上找替换元素 (这里采用后继进行替换)
//同时需要返回新的根节点给上级 /*
1.左右子树都在 (融合的情况) -- 找到右子树上最小元素进行替代
2.左子树在,但是没有右子树 (类似于删除最大值的逻辑)
3.右子树在,但是没有左子树 (类似于删除最小值的逻辑)
4.左右子树都空了,叶子节点(直接 return null) 即把空子树返回给上级节点(这一种情况已经包含在2, 3的写法中了) 先写 2, 3, 4 这类简单的情况 (返回值接在上层左子树还是右子树不确定,这要看上层的递归调用是哪种情况)
*/ if (root.right == null) {
Node leftNode = root.left;
root.left = null;
size--;
return leftNode; //包含了 leftNode 也为 null 的情况
} if (root.left == null) {
//类似于查找最小值的情况
Node rightNode = root.right;
root.right = null;
size--;
return rightNode;
} //左右子树都不空的情况(先找到替代节点 successorNode )
Node successorNode = getMin(root.right);
successorNode.right = removeMin(root.right); //返回的是删除最小元素后的根节点
successorNode.left = root.left; //置空原来 root 的 left 和 right
root.left = root.right = null;
return successorNode; //返回设置好的元素给上级 } return root;
}

上面的代码,找到要替换的元素时,分情况分析,即:

1.左右子树都在 (融合的情况) -- 找到右子树上最小元素进行替代

2.左子树在,但是没有右子树 (类似于删除最大值的逻辑) --- 代码类似

3.右子树在,但是没有左子树 (类似于删除最小值的逻辑) --- 代码类似

4.左右子树都空了,叶子节点(直接 return null) 即把空子树返回给上级节点(这一种情况已经包含在2, 3的写法中了)

这相当于在暴力穷举了,然后发现貌似确实不能合并,所以就这样划分了。

BST总结

这里会总一些可能有用的结论:

最重要的一条,BST的有序性(放入其中的元素,有序存储)是有维护代价的

  • 遍历:中序遍历,所有元素是升序排列。
  • 最大值,最小值: 不断向左找左子树,不断向右找右子树。
  • 前驱和后继(节点): 左子树中找最大值就是前驱(predecessor),右子树中找最小值就是后继(successor)。
  • 每个节点Node可以维护额外的属性,比如 count, rank, depth 等,适用于业务查询
  • floor 和 ceil: 寻找某个值的 floor 和 ceil
    • 该元素可以不在树上

整个看下来,也就是删除上要仔细考虑好几种情况以及破天荒的有 替换 的思维。

它的实现基本上就先这样(应用上有很多变形,但这与其实现无关,后面再说)。

老规矩,代码请参考 gayhub


最新文章

  1. AGS中通过FeatureServer插入数据失败、插入数据在WMTS请求中无法显示以及version概念的讨论
  2. mysql sum 和 count 函数 合并使用
  3. Windows 7下硬盘安装Ubuntu 14.04图文教程
  4. Java程序员必须知道的10个调试技巧
  5. jmeter的http sample使用
  6. Codeforces 295A Greg and Array
  7. Best Time to Buy and Sell Stock | &amp; || &amp; III
  8. VMware-workstation-full-10.0.3-1895310 CN
  9. PAT 07-3 求素数
  10. Linux下文件描述符
  11. java freemark生成word文档
  12. RPD添加网址、变量
  13. js中 substr(), substring(), slice()的区别
  14. undefined variable _session php
  15. Day037--Python--线程的其他方法,GIL, 线程事件,队列,线程池,协程
  16. golang基础学习及web框架
  17. python参数
  18. UIManager的字体颜色参数对照表
  19. Cloudstack
  20. SQL Server学习记录之获取每月每季度每年第一天和最后一天

热门文章

  1. 【译】深入理解G1的GC日志(一)
  2. 15号作品teamfinal使用体验
  3. David与Vincent的博弈游戏[树型DP]
  4. 一个测试文件与源文件位于不同模块时Jacoco覆盖率配置的例子
  5. MapReduce之WordCount
  6. 第15个算法-实现 Trie (前缀树)(LeetCode)
  7. 使用腾讯云提供的针对Nuget包管理器的缓存加速服务
  8. java实用类总结
  9. 蘑菇街工程师常用的Linux命令!
  10. web图形验证码逻辑