个人感觉,BST(二叉查找树)应该是众多常见树的爸爸,而不是弟弟,尽管 相比较而言 ,它比较简单。
理论定义,代码定义,满,完全等定义
不同于线性结构,树结构用于存储的话,通常操作效率更高。就拿现在说的二叉搜索树(排序树)来说,如果每次操作之后会让剩余的数据集减少一半,这不是太美妙了么?(当然不当的运用树结构存储,也会导致从 O(logN) 退化到 O(n))。
树里面常见的二叉树: BST, AVL,特殊的AVL(比如2-3树,即红黑树)。
不常见的有(包括多叉树): 线段树,Trie。(但我们说的不常见,不见得真的不常见,可能内核或者框架里面有用到,而写应用的没有用到;所以 常见或者不常见是没有一个确定概率基准的 ,个人把一般写应用的标准定义为基准)
二叉树就是一个最多: 一个爸爸,俩孩子 的树。
这样说,不形象,那形象点儿,直接上代码:
class Node { E e; Node left; Node right; }
当然,理论教科书上肯定不会这么傻帽的直接告诉你具体情况,它要绕一下,先来一个递归定义,把你绕晕,有图有真相:
然后一个节点也是树,空(null) 也是树。
这里有一些行话,包括上面的认为 logN 就是树高(在一般性的时间复杂度分析时),解释:
(深度从 1 开始,从上往下看)(自己画一下图,脑海中想一下就知道了)
其他行话,满二叉树,完全二叉树,尤其完全二叉树这个概念,后续树结构中有很重要的意义。
设二叉树的深度为h,除第 h 层(最后一层)外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树。
BST 说白了也是一种查找树,只是它的存储是有序的。
存入的值要么是可比较的,要么不能自身提供比较方法时,要主动传入比较器(Comparator)
也就是说,任意一个节点(它作为根节点看待),它的左子树的值都比它要小,右子树的值都比它大。(正是因为其存储的时候就有序,所以取的时候也高效)
哦,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; } }
清晰,简介,然后来补充增删查改,遍历等方法。
(它已经在树上了,就不要动了;或者你可以给 Node 增加一个属性 count,专门记录)
想象一下,放一个元素到树上,从根节点开始比较,以后的比较也是根据当前树的根元素,从而决定放在左子树,还是右子树上。(上面已经说了,相等的元素,什么也不干)
上面的分析其实已经很清楚了,那么下面考虑一下写法:
一般教科书上才会介绍循环的写法,大致如下: 《算法-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 == 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 的难点就在这里,想也知道啊,我删了一个元素,那么以这个元素为根的树怎么调整才能与当前元素的上级挂接(嫁接)好呢?
( 复杂可能在于,需要重新排序树 )
题外话:
我自个儿也是琢磨了一会儿,然后发现对付复杂,**先从特例上找规律,然后推广到一般**。 特例的规律如若不能推广到一般,那么就按照第二种思路: 分分合合。 * 分: 详细分析情况,不漏掉情况(暴力穷举) * 合: 这些具体的,分情况的方案能否合并,上升到一个层级规律 这样的好处,即便不能合,那么分情况的穷举(暴力解法),我们也能保底。(解决的不优雅,但按性质划分还是属于已解决的分类,有政治优势)
直接说结论,这里采用的是(找到元素)替换(覆盖)要被删除的元素
找一个怎么样的节点去替换啊? 找一个最接近被删除元素大小的元素替换 。
最接近的元素: 要删除元素的左子树上找最大,要删除元素的右子树上找最小。
先看看一棵子树上怎么找最大最小:(铺垫)
大致实现如下:
//获取树的最小值 (递归写法) 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,而不仅仅是值。
所以拿到最小元素的节点(当然顺带也要删除这个值),以及拿到最大元素的节点(顺带也要删除这个值),这两个值一定是最接近当前元素的,用它们中的一个,来覆盖当前被删除的元素的位置即可。
代码实现:
//删除最小值 (递归写法) 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; }
从上面删除最大,最小的逻辑,可以推广到一般情况,具体来说,举个例子:
若某节点的子树都是满的,则不那么容易,以后继为例,讲一下思路:
其实就是在右子树上找一个最节点最接近当前值的节点,替代当前要删除的节点。即 min(d->right)。换句话说,右子树中找到最小元素(其实是删除掉这个元素),然后在 d 的位置填补这个元素,图例如下:
上面把 s 的一切设置好(left, right),此时 s 就是整个调整完毕的子树根,把它返回给上级树的右子树即可。
这里已经可以看出,结论如下:
那么代码实现: (参考删除最大最小的逻辑,推到删除的一般逻辑,以后继为例)
/*找后继的实现*/ //用户指定删除某个值 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的有序性(放入其中的元素,有序存储)是有维护代价的
整个看下来,也就是删除上要仔细考虑好几种情况以及破天荒的有 替换
的思维。
它的实现基本上就先这样(应用上有很多变形,但这与其实现无关,后面再说)。
老规矩,代码请参考 gayhub 。