前面了解的无序链表和有序数组在性能方面至少在线性级别,没法用于数据量大的场合。接下来要学习的二叉查找树能够将链表插入的灵活性和有序数组查找的高效性结合起来,是计算机科学中最重要的算法之一。 一个二叉查找树(Binary Search Tree)是一颗二叉树,其中每一个结点都含有一个Comparable的键,以及相关联的值,且每一个结点的键都大于其左子树中任意结点的键,小于右子树中任意结点的键。算法
在二叉查找树中查找时,若是树是空的,则查找未命中;若是被查找的键和根结点的键相等,查找命中,不然就递归地在子树中继续查找,若是被查找的键小于根结点,就选择左子树,不然选择右子树。 查找算法的代码实现为:数组
public class BST<Key extends Comparable<Key>, Value> { private Node root; private class Node { private Key key; private Value val; private Node left, right; public int size; public Node(Key key, Value val, int size) { this.key = key; this.val = val; this.size = size; } } public Value get(Key key) { return get(root, key); } private Value get(Node x, Key key) { if (key == null) throw new IllegalArgumentException("calls get() with a null key"); if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp > 0) { return get(x.right, key); } else if (cmp < 0) { return get(x.left, key); } else { return x.val; } } }
其中,Node类用来表示二叉查找树的结点,每一个结点都含有键、值、左右连接和一个后面实现最大最小值等有序操做时会用到的结点计数器。性能
插入键值对时,首先进行查找,若是键已经存在于符号表中,则更新对应的值;若是查找未命中,就返回一个含有待插入键值对的新结点。学习
public void put(Key key, Value val) { root = put(root, key, val); } private Node put(Node x, Key key, Value val) { if (key == null) throw new IllegalArgumentException("calls put() with a null key"); if (x == null) return new Node(key, val, 1); int cmp = key.compareTo(x.key); if (cmp < 0) x.left = put(x.left, key, val); else if (cmp > 0) x.right = put(x.right, key, val); else { x.val = val; } x.size = size(x.left) + size(x.right) + 1; return x; }
插入操做的代码与查找相似,但插入操做会更新结点值或添加新结点,而且会更新结点计数器。 x.left = put(x.left, key, val); 相似这样的代码利用递归的特性简洁地实现告终点的添加。在递归调用时,至关于根据二分查找的逻辑,沿着树的某个分支一直向下查找,若是找到,就终止递归,更新结点的值,若是到了树的最底层也没找到,此时key==null成立,递归也会终止,同时新初始化的结点也已经被挂在x.left或者x.right了。 在递归推出的过程当中,至关于沿着树向上爬,每爬一层,*x.size = size(x.left) + size(x.right) + 1;*都会被执行,这样在添加结点后,相关路径上的全部结点的size都获得了更新。测试
插入新结点和未命中的查找都须要从整颗树的根结点搜索到树的最底层,因此二叉查找树的性能与树的形状有关,由于树的形状决定了树的深度。在最好的状况下,一个含有N个结点的树是彻底平衡的,全部的空连接都在最底层,距离根结点的距离为LgN;而在最坏的状况下,树的形状变成了一条链表,树的深度为N,将元素按顺序逐个插入到二叉查找树时,就能够形成这种状况。在通常的状况下,获得的树的形状与最好状况更加接近,二叉查找树的性能在对数级别。 英文原版《双城记》中大于7个字符的单词一共14350个,这些单词中不一样的单词有5737个,将这些单词做为键来测试不一样符号表实现的性能,结果以下: 图中横坐标表示插入单词的数量,纵坐标表示插入时的比较次数,灰点表示某次插入的实际比较次数,红点表示平均比较次数(比较总数/插入单词数量),前面学习过基于无序链表和有序数组的实现,平均次数分别为2246和484次,能够看到二叉查找树不管在单词比较次数仍是平均次数方面,都有了跨越数量级的进步。this
二叉查找树除了拥有较好的性能,还因其可以保持键的有序性而支持有序性相关的操做。code
一个结点的左子树的值都小于右子树,因此最小值可能在左子树中,若是左子树为空,则当前结点就是最小值。基于这种算法得出求最大值、最小值的代码实现为:对象
public Key min() { if (isEmpty()) throw new NoSuchElementException("calls min() with empty symbol table"); return min(root).key; } private Node min(Node x) { if (x.left == null) return x; else return min(x.left); } public Key max() { if (isEmpty()) throw new NoSuchElementException("calls max() with empty symbol table"); return max(root).key; } private Node max(Node x) { if (x.right == null) return x; else return max(x.right); }
关于向下取整,若是给定键小于根结点的键,那么小于等于给的键的最大值在根结点的左子树中,若是给定的键大于根结点,那么只有当根结点右子树中存在小于等于给定键的结点时,向下取整的值会出如今右子树中,不然根结点就是要找的值,向上取整的方法与此相似:blog
public Key floor(Key key) { Node n = floor(root, key); if (n == null) { return null; } else { return n.key; } } private Node floor(Node x, Key key) { if (x == null) { return null; } int cmp = key.compareTo(x.key); if (cmp == 0) return x; if (cmp < 0) return floor(x.left, key); Node n = floor(x.right, key); if (n == null) { return x; } else { return n; } } public Key ceiling(Key key) { if (n == null) { return null; } else { return n.key; } } private Node ceiling(Node x, Key key) { if (x == null) { return null; } int cmp = key.compareTo(x.key); if (cmp == 0) return x; if (cmp > 0) return ceiling(x.right, key); Node n = ceiling(x.left, key); if (n == null) { return x; } else { return n; } }
排名从0开始,选择方法select(k)会返回排名为的键,树中有k个小于它的键。若是左子树中的结点数t大于k,就继续在左子树中查找,若是t等于k,那么根结点就是要找的键,若是t小于k,就在右子树中查找排名为k-t-1的键,由此获得的代码为:排序
public Key select(int k) { return select(root, k).key; } private Node select(Node x, int k) { if (x == null) { return null; } int t = size(x.left); if (t > k) { return select(x.left, k); } else if (t < k) { return select(x.right, k - t - 1); } else { return x; } }
排名rank()方法是选择方法的逆方法,它返回给定键的排序。若是给定键与根结点相等,那么键的排名就是根结点左子树中的结点总数t;若是给定键小于根结点,在左子树中继续递归计算;若是给定键大于根结点,就返回t+1再加上它在右子树中的排名。
public int rank(Key key) { return rank(key, root); } private int rank(Key key, Node x) { if (x == null) { return 0; } int cmp = key.compareTo(x.key); if (cmp > 0) { return size(x.left) + rank(key, x.right) + 1; } else if (cmp < 0) { return rank(key, x.left); } else { return size(x.left); } }
范围查找要求返回给定范围内的全部键,这里会用到遍历二叉树的基本方法-中序遍历。先遍历左子树中的全部键,而后遍历根结点,最后是右子树中的全部键,这一过程递归地进行,就能够按从小到大的顺序遍历完全部结点。
public void keys(Node x, Queue<Key> queue, Key lo, Key hi) { if (x == null) return; int cmplo = lo.compareTo(x.key); int cmphi = hi.compareTo(x.key); if (cmplo < 0) keys(x.left, queue, lo, hi); if (cmplo <= 0 && cmphi >= 0) queue.enqueue(x.key); if (cmphi > 0) keys(x.right, queue, lo, hi); }
删除最小键时,须要不断地深刻根结点的左子树,直到碰见一个空连接,而后将指向该结点的连接指向该结点的右子树,要被删除的结点由于没有被任何对象引用,随后就会被垃圾回收器清理掉。删除最大键的过程相似。
public void deleteMin() { root = deleteMin(root); } private Node deleteMin(Node x) { if (x.left == null) return x.right; x.left = deleteMin(x.left); x.size = size(x.left) + size(x.right) + 1; return x; } public void deleteMax() { root = deleteMax(root); } private Node deleteMax(Node x) { if (x.right == null) return x.left; x.right = deleteMax(x.right); x.size = size(x.left) + size(x.right) + 1; return x; }
在不断深刻左子树的时候,除非碰见空连接,deleteMin(Node x)方法都返回结点x,只有最后一次递归才将上个结点指向x.right,递归退出时,会更新路径上的结点计数器。
二叉查找树中最难实现的方法就是delete()方法了,删除最大、最小键时,被删除的结点的两个子结点中,只有一个不为空,但通常的结点都会有两个子结点,删除这个结点后,须要合理处理它的两个子结点。T.Hibbard在1962年提出了解决这个难题的第一个方法,在删除结点x后用它的后继结点填补它的位置。由于x有一个右子结点,由此它的后继结点就是其右子树中的最小结点。这样的替换仍然能保证树的有序性,由于x.key和它的后继结点之间不存在其余的键。完成这个操做须要4步:
public void delete(Key key) { root = delete(root, key); } private Node delete(Node x, Key key) { if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp < 0) x.left = delete(x.left, key); else if (cmp > 0) x.right = delete(x.right, key); else { if (x.right == null) return x.left; if (x.left == null) return x.right; Node t = x; x = min(t.right); x.right = deleteMin(t.right); x.left = t.left; } x.size = size(x.left) + size(x.right) + 1; return x; }