一、什么是二叉搜索树
二叉搜索树(Binary Search Tree,简称 BST)是一种特殊的二叉树结构。它的核心思想是:通过结点之间的大小关系,把数据组织成一棵可以快速定位的树。只要树保持平衡,查找、插入和删除的效率都会非常高。
理解二叉搜索树,关键要抓住一条贯穿整棵树的有序性规则:左子树 < 根节点 < 右子树。
二、二叉搜索树的性质
二叉搜索树的性质可以归纳为以下三点:
- 左子树小于根节点:任意结点的左子树中所有结点的值都小于该结点的值。
- 右子树大于根节点:任意结点的右子树中所有结点的值都大于该结点的值。
- 递归成立:不仅根节点满足上述关系,它的左右子树本身也各自是一棵二叉搜索树,同样满足这个性质。
也就是说,这条规则不是只对根节点生效,而是对树中的每一个结点都成立。这是二叉搜索树与普通二叉树最本质的区别。
三、二叉搜索树的作用
二叉搜索树最主要的作用是方便查找。由于数据按照大小关系有序组织,查找时不需要遍历整棵树,而是可以像查字典一样不断缩小范围:
- 从根节点开始比较目标值。
- 如果目标值小于当前结点,就到左子树继续查找。
- 如果目标值大于当前结点,就到右子树继续查找。
- 如果相等,说明找到了目标结点。
这种"每次排除一半左右"的思路,正是二叉搜索树高效的根本原因。
四、时间复杂度分析
在理想情况下,也就是树比较平衡时,二叉搜索树的查找、插入和删除操作的时间复杂度都是 O(logN)。因为每次比较都能把搜索范围缩小到原来的一半左右,树的高度约为 logN,操作次数与树高成正比。
但需要注意最坏情况:如果插入的数据本身是有序的(例如依次插入 1、2、3、4、5),树就会退化成一条单分支的链状结构,此时树的高度变成 N,查找、插入和删除的时间复杂度都会退化为 O(N)。
小结:二叉搜索树的效率取决于树的高度。平衡时是 O(logN),退化成单分支时是 O(N)。
五、中序遍历得到有序序列
二叉搜索树有一个非常重要的特性:对它进行中序遍历,会得到一个有序的序列。
中序遍历的顺序是"左子树 → 根节点 → 右子树"。由于左子树的所有值都小于根节点,右子树的所有值都大于根节点,按照这个顺序访问,输出的结果自然就是从小到大排列的。
因此,中序遍历常被用来验证一棵二叉树是否为合法的二叉搜索树,也可以用来对树中元素进行排序输出。
六、插入结点的注意事项
在实现二叉搜索树的插入操作时,一般默认不允许插入重复的值。因为二叉搜索树的核心价值在于有序性和唯一性,如果允许重复值,就需要额外约定重复值放在左边还是右边,容易让查找和删除逻辑变得复杂。
常见的处理方式是:插入时如果发现值已存在,则直接忽略该次插入,或者根据业务需要维护一个计数器来记录重复次数。
七、Java 代码实现
下面给出一个完整的 Java 版二叉搜索树实现,包含查找、插入、删除和中序遍历四个核心操作。代码采用迭代方式实现查找与插入,删除操作则单独抽取出 removeNode 方法处理三种情况。为便于阅读,下面把四个核心操作分别拆开讲解。
7.1 查找操作
search 方法从根节点出发,用 cur 指针沿树向下移动:目标值小于当前结点就往左走,大于就往右走,相等则直接返回该结点。循环结束时仍未命中,说明树中不存在该值,返回 null。整个过程只依赖一条从根到叶的路径,因此时间复杂度与树高成正比。
java
// 查找
public TreeNode search(int val) {
if (root == null) {
return null;
}
TreeNode cur = root;
while (cur != null) {
if (cur.val > val) {
cur = cur.left;
} else if (cur.val == val) {
return cur;
} else {
cur = cur.right;
}
}
return null;
}
7.2 插入操作
insert 方法需要额外维护一个 prev 指针,用来记录 cur 的父结点。循环中一旦发现 cur.val == val,说明值已存在,直接返回 false,这与前面提到的"默认不允许插入重复值"保持一致。循环结束后 cur 为 null,此时根据 prev.val 与待插入值的大小关系,把新结点挂到 prev 的左孩子或右孩子上。
java
// 插入
public boolean insert(int val) {
TreeNode node = new TreeNode(val);
if (root == null) {
root = node;
return true;
}
TreeNode cur = root;
TreeNode prev = null;
while (cur != null) {
if (cur.val > val) {
prev = cur;
cur = cur.left;
} else if (cur.val == val) {
return false;
} else {
prev = cur;
cur = cur.right;
}
}
if (prev.val > val) {
prev.left = node;
} else {
prev.right = node;
}
return true;
}
7.3 删除操作
删除是二叉搜索树中最复杂的操作,removeNode 方法按待删除结点的孩子情况分三类处理:
- 左孩子为空 :直接用右孩子顶替当前结点。若删除的是根节点,则让
root指向root.right;否则根据cur是父结点的左孩子还是右孩子,把cur.right接到对应位置。 - 右孩子为空:与上一种情况对称,用左孩子顶替当前结点。
- 左右孩子都不为空 :先到右子树中找最左侧的结点
target,它就是当前结点的中序后继,也是右子树中的最小值。把target.val覆盖到cur.val上,再把target从其父结点上摘除。由于target一定没有左孩子,摘除时只需把它的右孩子接到父结点上即可。
java
// 删除
public boolean remove(int key) {
if (root == null) {
return false;
}
TreeNode cur = root;
TreeNode prev = null;
while (cur != null) {
if (cur.val > key) {
prev = cur;
cur = cur.left;
} else if (cur.val == key) {
removeNode(prev, cur);
return true;
} else {
prev = cur;
cur = cur.right;
}
}
return false;
}
private void removeNode(TreeNode prev, TreeNode cur) {
if (cur.left == null) {
if (cur == root) {
root = root.right;
return;
} else if (prev.right == cur) {
prev.right = cur.right;
} else {
prev.left = cur.right;
}
} else if (cur.right == null) {
if (cur == root) {
root = root.left;
return;
} else if (prev.left == cur) {
prev.left = cur.left;
} else {
prev.right = cur.left;
}
} else {
TreeNode targetParent = cur;
TreeNode target = cur.right;
while (target.left != null) {
targetParent = target;
target = target.left;
}
cur.val = target.val;
if (targetParent.right == target) {
targetParent.right = target.right;
} else {
targetParent.left = target.right;
}
}
}
提示:这里采用"值覆盖 + 删除后继结点"的方式,避免了复杂的指针交换,是面试和工程中都比较常见的写法。
7.4 中序遍历
inOrder 方法是一个标准的递归实现,按照"左子树 → 根节点 → 右子树"的顺序访问。把它作用在二叉搜索树上,输出的序列天然有序,因此常用来验证树的正确性,例如在插入和删除若干元素后调用一次 inOrder(root),观察输出是否严格递增。
java
// 中序遍历
public void inOrder(TreeNode root) {
if (root == null) {
return;
}
inOrder(root.left);
System.out.print(root.val + " ");
inOrder(root.right);
}
7.5 完整类定义
把上面四个方法放回同一个类中,就得到完整的 BinarySearchTree 实现。其中 TreeNode 作为静态内部类定义结点结构,root 保存整棵树的根引用。
java
package searchtree;
public class BinarySearchTree {
public TreeNode root;
static class TreeNode {
public int val;
public TreeNode left;
public TreeNode right;
public TreeNode(int val) {
this.val = val;
}
}
// 查找、插入、删除、中序遍历的具体实现见上文 7.1 ~ 7.4
}
八、完整测试示例
下面用一个完整的测试类验证前面实现的二叉搜索树。测试依次插入 30、20、40、35、50、38、60,然后删除 40,最后通过中序遍历观察输出结果。这个示例同时覆盖了插入、删除和中序遍历三个操作,比单个方法的零散调用更能说明整棵树的行为。
java
public class Test {
public static void main(String[] args) {
BinarySearchTree binarySearchTree = new BinarySearchTree();
binarySearchTree.insert(30);
binarySearchTree.insert(20);
binarySearchTree.insert(40);
binarySearchTree.insert(35);
binarySearchTree.insert(50);
binarySearchTree.insert(38);
binarySearchTree.insert(60);
binarySearchTree.remove(40);
// BinarySearchTree.TreeNode search = binarySearchTree.search(50);
// System.out.println(search.val);
binarySearchTree.inOrder(binarySearchTree.root);
}
}
8.1 插入过程分析
按顺序插入 7 个元素后,树的结构如下:
- 30 作为根节点。
- 20 小于 30,挂在 30 的左孩子上。
- 40 大于 30,挂在 30 的右孩子上。
- 35 小于 40 且大于 30,挂在 40 的左孩子上。
- 50 大于 40,挂在 40 的右孩子上。
- 38 小于 40、大于 35,挂在 35 的右孩子上。
- 60 大于 50,挂在 50 的右孩子上。
此时中序遍历的结果为:20 30 35 38 40 50 60,是一个严格递增的序列,说明树的结构是正确的。
8.2 删除 40 的过程
待删除的结点 40 左右孩子都不为空,属于删除操作中最复杂的一种情况。按照 removeNode 的逻辑,程序会到 40 的右子树中寻找最左侧的结点,也就是右子树的最小值:
- 从 40 的右孩子 50 出发,50 有左孩子 35,继续向左。
- 35 有右孩子 38,但没有左孩子,因此 35 就是右子树中的最小值,即 40 的中序后继。
- 把 35 的值覆盖到 40 所在结点上,此时该结点的值变为 35。
- 再把 35 这个后继结点从其父结点 50 上摘除,由于 35 没有左孩子,只需把它的右孩子 38 接到 50 的左孩子位置。
删除完成后,树中不再有 40,中序遍历的结果变为:20 30 35 38 50 60,依然保持有序。
8.3 运行结果
text
20 30 35 38 50 60
可以看到,删除 40 之后,原本位于右子树最左侧的 35 顶替了它的位置,整棵树仍然满足二叉搜索树的性质。如果把代码中被注释掉的两行取消注释,还可以额外验证 search(50) 能正确返回值为 50 的结点。
提示:测试时建议多构造几种删除场景,例如删除叶子结点、只有一个孩子的结点、有两个孩子的结点以及根节点,这样能更全面地验证
removeNode中各个分支的正确性。
九、总结
- 二叉搜索树满足:左子树 < 根节点 < 右子树,且对每个结点递归成立。
- 主要作用是高效查找,查找、插入、删除在平衡情况下为 O(logN)。
- 最坏情况退化为单分支树,时间复杂度变为 O(N)。
- 中序遍历二叉搜索树可以得到有序序列。
- 插入结点时一般不允许插入重复值。