二叉查找树(Binary Search Tree,BST)是一种特殊的二叉树,BST通过定义节点的左孩子和右孩子的约定关系,提升了二叉树节点的搜索效率。

相比普通二叉树,BST有以下额外的特点:

  • 对于任意一个节点n,其左子树下的每个后代节点的值,都小于节点n的值。
  • 对于任意一个节点n,其右子树下的每个后代节点的值,都大于节点n的值。

以下是一个BST的示例。
二叉查找树.svg
可能看着这个二叉树比较丑陋,但是这的确是一个现实中常常会碰到的BST。有二叉树排序经验的读者可能会觉得这个二叉树十分熟悉,的确,BST也常常用来做排序,这和你所看到和用到的二叉树排序几乎是一样的。

在使用BST之前,需要先对之前定义的二叉树节点类稍微扩展一下。

  1. public class BSTNode extends Node implements Comparable<BSTNode> {
  2. public BSTNode(Integer value) {
  3. super(value);
  4. }
  5. public bool isLeftChild() {
  6. return this.getParent()
  7. .map(parent -> this.compareTo(parent) < 0)
  8. .orElse(false);
  9. }
  10. public bool isRightChild() {
  11. return this.getParent()
  12. .map(parent -> this.compareTo(parent) > 0)
  13. .orElse(false);
  14. }
  15. @Override
  16. public int compareTo(BSTNode other) {
  17. return this.value - other.getValue();
  18. }
  19. }

节点搜索

在理论上,使用BST查找节点可以使时间减半。要搜索一个节点,BST需要利用以下步骤。

  1. 如果给定要搜索的值c为空,那么这个值必定不在BST中。
  2. 取得当前节点n,如果是搜索起始,此时的节点n是BST的根节点。
  3. 比较给定值c和节点n,如果值相同,则节点n为所需要的节点。
  4. 如果节点n的值大于给定值c,那么所需要的节点应该存在于节点n的左子树中。此时可以向节点n的左子树前进一层,即将节点n的左孩子作为下一次比较的节点n。
  5. 如果节点n的值小于给定值c,那么所需要的节点应该存在于节点n的右子树中。此时可以向节点n的右子树前进一层,即将节点n的右孩子作为下一次比较的节点n。

BST的这种搜索策略,查找一个节点不需要遍历整个树,只需要遍历树中的一个路径即可,这个遍历的复杂度为二叉查找树 - 图2。以下给出一个BST的搜索方法实现示例。

  1. public class BinarySearchTree {
  2. public Node search(Integer value) {
  3. LinkedList<BSTNode> queue = new LinkedList<>();
  4. queue.push(this.root);
  5. while (!queue.isEmpty()) {
  6. Node pointer = queue.pollFirst();
  7. if (pointer.getValue() == value) {
  8. return pointer;
  9. }
  10. if (pointer.getValue() > value) {
  11. pointer.getLeftChild().ifPresent(queue::push)
  12. }
  13. if (pointer.getValue() < value) {
  14. pointer.getRightChild().ifPresent(queue::push);
  15. }
  16. }
  17. }
  18. }

但是对于BST来说,其搜索效率完全取决于其树的拓扑结构。如果树中只存在一条路径,那么搜索的复杂度就会变成二叉查找树 - 图3。所以这也是后面引入自平衡二叉查找树的原因。

插入节点

由于BST是一个十分有序的结构,任何违反BST规则的树结构都会使BST的搜索失效。所以向BST中插入一个节点的时候,最重要的是定位新节点的父节点,而且新插入的节点始终是作为叶子节点的,不存在需要重新排列的问题。在BST中定位新节点n的父节点的步骤如下。

  1. 如果是搜索起始,当前节点c为BST的根节点。
  2. 如果节点c为空,则节点c的父节点p即为节点n的父节点。如果节点n的值小于父节点p的值,那么节点n将作为节点p的左孩子,否则节点n作为节点p的右孩子。
  3. 如果节点c与节点n的值相等,那么说明当前正在插入一个重复节点,此时可以选择丢弃节点n或者抛出错误。
  4. 如果节点n小于节点c的值,那么节点n的父节点在节点c的左子树中,此时需要取得节点c的左孩子作为新的节点c继续进行比较。
  5. 如果节点n大于节点c的值,那么节点n的父节点在节点c的右子树中,此时需要取得节点c的右孩子作为新的节点c继续进行比较。

以下给出一个向BST中插入新节点的示例方法。

  1. public class BinarySearchTree {
  2. public void insert(Node newNode) throws RepeatValueException {
  3. LinkedList<BSTNode> queue = new LinkedList<>();
  4. queue.push(this.root);
  5. while (!queue.isEmpty()) {
  6. BSTNode pointer = queue.pollFirst();
  7. if (pointer.compareTo(newNode) == 0) {
  8. throw new RepeatValueException();
  9. }
  10. if (pointer.compareTo(newNode) > 0) {
  11. pointer.getLeftChild()
  12. .ifPresentOrElse(queue::push,
  13. () -> pointer.attachLeftChild(newNode));
  14. }
  15. if (pointer.compareTo(newNode) < 0) {
  16. pointer.getRightChild()
  17. .ifPresentOrElse(queue::push,
  18. () -> pointer.attachRightChild(newNode));
  19. }
  20. }
  21. }
  22. }

删除节点

从BST中删除一个节点的难度比插入节点更大。如果被删除的是一个非叶子节点,那就意味着树的断裂,此时就必须选择一个合适的节点来填补。所以在定位被删除的节点之后,最重要的事情是选择一个合适的节点来代替被删除节点的位置。一般在删除BST的非叶子节点时,会采用以下策略来完成整个操作。

  1. 如果被删除的节点没有右孩子,那么就直接选择它的左孩子代替原来的节点。
  2. 如果被删除的节点没有左孩子,那么就直接选择它的右孩子代替原来的节点。
  3. 如果被删除的节点既有左孩子又有右孩子,那么就需要使用被删除节点的右子树中最小值的节点来代替原来的节点。

在这个策略中,第三条策略是最复杂的,但是并不难理解。被删除节点的值必定要小于右子树中所有的值,且大于左子树中所有的值,所以,选择右子树中最小的值来代替被删除节点,是完全能够保证BST继续遵守约定规则的。

以下是操作BST删除节点的例程,这个例程在删除节点的时候,首先需要找到被删除节点的父节点,然后定位用来替代被删除节点的节点,在最复杂的情况下,需要做两次节点删除操作。

  1. public class BinarySearchTree {
  2. public BSTNode minimum(BSTNode root) {
  3. return root.getLeftChild()
  4. .map(this::minimum)
  5. .orElse(root);
  6. }
  7. public void remove(Node pendingDelete) {
  8. BSTNode parent = pendingDelete.getParent();
  9. if (!pendingDelete.getRightChild().isPresent()) {
  10. pendingDelete.getLeftChild().ifPresent(
  11. pendingDelete.isLeftChild() ? parent::attachLeftChild : parent::attachRightChild);
  12. } else if (!pendingDelete.getLeftChild().isPresent()) {
  13. pendingDelete.getRightChild().ifPresent(
  14. pendingDelete.isLeftChild() ? parent::attachLeftChild : parent::attachRightChild);
  15. } else {
  16. BSTNode minimumNode = this.minimum(pendingDelete);
  17. this.remove(minimumNode);
  18. pendingDelete.getLeftChild().ifPresent(minimumNode::attachLeftChild);
  19. pendingDelete.getRightChild().ifPresent(minimumNode::attachRightChild);
  20. if (pendingDelete.isLeftChild()) {
  21. parent.attachLeftChild(minimumNode);
  22. } else {
  23. parent.attachRightChild(minimumNode);
  24. }
  25. }
  26. }
  27. }

二叉查找树的缺点

BST的缺点在于,如果没有能够很好的规划树的拓扑结构,那么BST将会失去它所有的搜索优势,节点检索的复杂度也会从二叉查找树 - 图4变为二叉查找树 - 图5。这种情况在大量节点缺失单侧子树时,最常发生,所以在使用BST时,需要仔细规划树的拓扑结构,或者采用后面介绍的自平衡二叉查找树。