二分搜索树
什么是二叉树,就是一个父亲节点,一个左子节点和一个右子节点。左子节点和右子节点都可以为空。
二分搜索树就是在二叉树的基础上具有排序的性质的。如下图所示:
性质就是:
- 左子节点比父节点小
- 右子节点比父节点大
排序性质是可以自定义的,只不过上面的性质是比较典型的而已。
同时二分搜索树不一定是满的二叉树,有可能退化成链表。
定义二分搜索树
定义节点
private class Node {
public E e;
public Node left, right;
public Node(E e) {
this.e = e;
left = null;
right = null;
}
}
定义二分搜索树
public class BST<E extends Comparable> {
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;
}
}
二分搜索树的插入
如下是一棵二分搜索树,我们需要往这棵二分搜索树中添加新的元素45
可以看到,插入新元素的过程其实很简单,无非就是:
- 比较当前节点currentNode与插入节点newNode的大小
- 如果currentNode > newNode,就去currentNode的左子树去查找合适的位置插入
- 如果currentNode < newNode,就去currentNode的右子树中查找合适的位置插入
图中有演示>=号,这里我们看做是>号就可以了,因为我不想插入相同的元素,我也会尽量避免插入重复的元素的。所以我的插入代码如下所示:
public void add(E e) {
root = add(root, e);
}
// 向以node为根的二分搜索树插入元素e, 递归算法
private Node add(Node node, E e) {
if (node == null){
size ++;
return new Node(e);
}
if (e.compareTo(node.e) < 0){
node.left = add(node.left, e);
}else if (e.compareTo(node.e) > 0){
node.right = add(node.right, e);
}
// 如果相等就直接返回了,就不做处理了,不插入相同元素
return node;
}
我们知道,二分搜索树是天然支持递归的,所以我们采用的是递归的方式来执行的。
二分搜索树的查找
同理,其实查找元素的过程也是类似的,如下图所示:
查找的代码如下:
public boolean contains(E e){
return contains(root, e);
}
private boolean contains(Node node, E e){
if (node == null)
return false;
if (e.compareTo(node.e) == 0)
return true;
else if (e.compareTo(node.e) < 0){
return contains(node.left, e);
}else {
return contains(node.right, e);
}
}
获取最大值/最小值
在讲删除节点之前,我们先通过查找最大值/最小值。 其实查找最大值,无非就是往右子树一直查找,直到找不到为止。同理,查找最小值就是往左子树中查找,直到为null。
public E minimum(){
if (size == 0)
throw new IllegalArgumentException("BST is empty");
return minimum(root).e;
}
private Node minimum(Node node){
if (node.left == null)
return node;
return minimum(node.left);
}
public E maximum(){
if (size == 0)
throw new IllegalArgumentException("BST is empty");
return maximum(root).e;
}
private Node maximum(Node node){
if (node.right == null)
return node;
return maximum(node.right);
}
删除最大值/最小值
删除最大值/最小值的方法也很简单,直接找下一个节点替代即可
public E removeMin(){
E ret = minimum();
root = removeMin(root);
return ret;
}
private Node removeMin(Node node) {
if (node.left == null) {
Node rightNode = node.right;
node.right = null;
size --;
return rightNode;
}
node.left = removeMin(node.left);
return node;
}
public E removeMax(){
E ret = maximum();
root = removeMax(root);
return ret;
}
private Node removeMax(Node node) {
if (node.right == null) {
Node leftNode = node.left;
node.left = null;
size --;
return leftNode;
}
node.right = removeMax(node.right);
return node;
}
代码是简单的,不过要特别说明一下,我们是递归调用的,一旦我们找到了可以替代的节点,直接将可以替代的节点覆盖掉,然后把替代节点的原先位置置空,然后返回回去。
node.left = removeMin(node.left);这里的意思就是,删除左子树中的最小值,并把成功删除最小值之后的左子树返回回去,赋值成新的左子树。
删除节点
二分搜索树的删除节点相对来说会复杂一些,因为在删除之前要找到后继节点。 比如在下面这棵二分搜索树中。
假设,我们要删除的节点是35,演示如下:
可以看到,由于35这个节点只有左子树,直接返回左子树给root节点了。
同理,如果只有一个右子树,我们就直接把右子树返回给root节点。也就是root.left = deleteNode.right;这样原先要删除的节点没有指针了,也就会被直接回收了。
再来看这个例子,假设,我们要删除的节点是49。
- 从root节点39开始找,发现49 > 39
- 去右子树中查找,49 > 45
- 再去45的右子树中查找,发现 49 == 49
- 执行删除操作
删除的操作演示如下:
我们把这一步称为查找需要删除节点的后继节点。很容易的发现,就是往待删除节点(49)的右子树中查找最小值,查找到后继节点后,缓存起来,然后去待删除节点(49)的右子树中,删除最小值,把删除最小值后的新子树当做后继节点的右子树,把待删除的节点(49)的左子树当做后继节点的左子树。整个删除操作完成后,要返回删除之后的子树给到父节点,这样就真正完成删除操作了。
代码如下:
// 删除以node为根的二分搜索树中值为e的节点,地柜算法
// 返回删除节点后新的二分搜索树的根
private Node remove(Node node, E e){
if (node == null)
return null;
if (e.compareTo(node.e) < 0) {
node.left = remove(node.left, e);
return node;
}else if (e.compareTo(node.e) > 0){
node.right = remove(node.right, e);
return node;
}else { // e == node.e
if (node.left == null) {
Node rightNode = node.right;
node.right = null;
size --;
return rightNode;
}
if (node.right == null) {
Node leftNode = node.left;
node.left = null;
size --;
return leftNode;
}
// 待删除节点左右字数均不为空的情况
// 找到比待删除节点大的最小节点,即待删除节点左右子树的最小节点
// 用这个节点顶替待删除节点的位置
Node successor = minimum(node.right);
successor.right = removeMin(node.right);
successor.left = node.left;
node.left = node.right = null;
return successor;
}
}
删除操作是运用到二分搜索树天然的递归性质,可以好好体会下这种递归的性质。
二分搜索树的遍历
二分搜索树的遍历分为前中后序遍历,还有层序遍历。关于二分搜索树的前中后序遍历挺简单的,借助二分搜索树的天然递归就可以实现了。代码如下:
private void inOrder(){
inOrder(root);
}
private void inOrder(Node node) {
if (node == null)
return;
inOrder(node.left);
System.out.println(node.e);
inOrder(node.right);
}
这是中序遍历,后序遍历跟前序遍历差不多的,调整下位置就可以了。
层序遍历
层序遍历需要借助额外的数据结构——队列来实现,这里不做过多解释了,可以一边画图一边了解,很简单的。
public void levelOrder(){
Queue<Node> q = new LinkedList<>();
q.add(root);
while (!q.isEmpty()){
Node cur = q.remove();
System.out.println(cur.e);
if (cur.left != null) {
q.add(cur.left);
}
if (cur.right != null){
q.add(cur.right);
}
}
}
二分搜索树的时间复杂度
我们知道,二分搜索树具有天然递归的性质,我们知道,如果递归太深了,可能会出现stackoverflow的异常。那为什么二分搜索树的实现要采用递归来实现?其实很简单,二分搜索树的递归层次跟二分搜索树的深度有关。
二分搜索树的深度:
如果我们把根节点所在的层称为第0层,那么二分搜索树的深度就像上图所示的那样。
了解到了二分搜索树的深度之后,我们就可以来讨论时间复杂度了。
假设,我们是一棵满二叉树
我们可以观察到:
| 层数h | 满二分搜索树节点总数 n |
| 0 | 1 |
| 1 | 3 |
| 2 | 7 |
| 3 | 15 |
很容易看出来,深度和总的节点数存在这样的关系:
这就是一个等比数列,根据等比数列的求和公式,可以得到:
得到
可以看到,二分搜索树查找的最好时间复杂度是,插入的时间复杂度也是这种分析方法。
既然有最好的时间复杂度,那最坏的呢?最坏的就是退化成链表的二分搜索树,如下图所示:
如果退化成链表的二分搜索树,查找/插入的时间复杂度就是了。为了避免二分搜索树出现退化成链表的情况,所以后面才会有平衡二叉树AVL以及红黑树这些数据结构。
有时间再介绍AVL以及红黑树。
所以经过我们分析,深度大概是,拿1000000的规模来说,也不过大概等于19左右而已,如下图:
所以在现代计算机的基础上,基本不用担心递归导致栈溢出的问题。