前面介绍了树和二叉树的基本概念以及存储。
今天再来学习一下一种特殊的二叉树,二叉查找树。二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。
散列表也是支持这些操作的,并且散列表更加的高效,时间复杂度是O(1)。既然有了这么高效的散列表,为什么还需要二叉查找树呢?使用二叉树的地方是不是都可以替换成散列表呢?有没有哪些地方是散列表做不了,必须要用二叉树来做的呢?
1.二叉查找树
又称为二叉搜索树,顾名思义,二叉查找树是为了实现快速查找而生的。不过,还支 持快速插入、删除一个数据。
1.1二叉查找树的结构
二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。如下图:
1.2二叉查找树的查找操作
查找过程:从根节点开始查找,如果查找的值等于根节点就返回。如果小于根节点,那么就在左子树中递归查找。如果大于根节点,则在右子树中递归查找。
java代码实现二叉查找:
public TreeNode find(int value){ TreeNode node = root; while (node != null){ if (value < node.value){ node = node.leftNode; } else if (value > node.value){ node = node.rightNode; }else { return node; } } return null; }
1.3二叉查找树的插入操作
二叉查找树的插入过程有点类似查找操作。新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。
如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理, 如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。
java代码实现插入:
public void insert(int value){ if (root == null){ root = new TreeNode(value); return; } TreeNode curNode = root; while (curNode != null){ if (value < curNode.value){ //在左子树 if (root.leftNode == null){ root.leftNode = new TreeNode(value); } curNode = root.leftNode; }else { //在右子树 if (root.rightNode == null){ root.rightNode = new TreeNode(value); } curNode = root.rightNode; } } }
1.4二叉查找树的删除
相对于二叉查找树的查找和插入,二叉查找树的删除就比较复杂了。针对要删除节点的子节点个数的不同,我们需要分三种情况来处理:
1,如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为null。比下图中的删除节点55。
2,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比下图中的删除节点13。
3,如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉 这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如下图中的删除节点18。
java实现删除:
/** * 删除 * @param value */ public void delete(int value){ //先找到要删除的节点 TreeNode delNode = null;//要删除的节点 TreeNode delNodeParent = root;//要删除节点的父节点 TreeNode curNode = root;//当前遍历节点 boolean isLeftNode = false; while (curNode != null){ if (value < curNode.value){ delNodeParent = curNode; curNode = curNode.leftNode; isLeftNode = true; }else if (value > curNode.value){ delNodeParent = curNode; curNode = curNode.rightNode; isLeftNode = false; }else { delNode = curNode; break; } } if (delNode == null){ return; } //要删除的节点有两个子节点 if (delNode.leftNode != null && delNode.rightNode != null){ TreeNode minLeftNode = delNode.rightNode;//被删除节点的右子树最小的左节点 TreeNode minLeftNodeParent = delNode;//表示minLeftNode的父节点 while (minLeftNode.leftNode != null){ //找出被删除节点的右子树最小的左节点 minLeftNodeParent = minLeftNode; minLeftNode = minLeftNode.leftNode; } if (delNode == root){ //删除的是根节点 root = minLeftNode == null?delNode.leftNode:minLeftNode; }else { //替换删除的节点 if (isLeftNode){ delNodeParent.leftNode = minLeftNode; }else { delNodeParent.rightNode = minLeftNode; } //被删除节点的右子树最小的左节点的父节点 连接最小左节点的右节点 minLeftNodeParent.leftNode = minLeftNode.rightNode; } //被删除节点的右子树最小的左节点 连接被删除节点的左右节点 minLeftNode.leftNode = delNode.leftNode; minLeftNode.rightNode = delNode.rightNode; }else { //删除的节点有一个子节点或者没子节点 if (isLeftNode){ delNodeParent.leftNode = delNode.leftNode == null ? delNode.rightNode : delNode.leftNode; }else { delNodeParent.rightNode = delNode.leftNode == null ? delNode.rightNode : delNode.leftNode; } } }
解答开篇
我们在散列表那节中讲过,散列表的插入、删除、查找操作的时间复杂度可以做到常量级的O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、
查找操作时间复杂度才是O(logn),相对散列表,好像并没有什么优势,那我们为什么还要用二叉查找树呢? 我认为有下面几个原因:
第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在O(n)的时间复杂度 内,输出有序的数据序列。
第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在O(logn)。
第三,笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比logn小,所以实际的查找速度可能不一定 比O(logn)快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。
综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。