二叉搜索树 & TreeMap 和 TreeSet 介绍
二叉搜索树
二叉搜索树是什么
二叉搜索树又被叫做二叉排序树, 根据二叉排序树的这个名字我们也可以知道其内部的元素是有序的, 那么它是如何确定有序的呢? 接下来我们先来看一下二叉搜索树的定义
二叉搜索树要么是一颗空树, 要么是具有如下性质的二叉树
- 若它的左子树不为空, 则左子树所有节点的值小于根节点的值
- 若它的右子树不为空, 则右子树所有节点的值大于根节点的值
- 它的左右子树也是二叉搜索树
其实也很简单, 就是所有节点的左子树小于当前根节点, 右子树大于当前根节点的二叉树就是二叉搜索树. 例如下面就是一棵二叉搜索树
同时我们也可以看到, 如果按照中序遍历去遍历这一棵二叉搜索树, 那么就可以得到一个有序序列. 例如上面的这个二叉搜索树的中序遍历结果就是0 1 2 3 4 5 6 7 8 9
模拟实现
初始化
我们依旧是先创建一个类用于表示二叉搜索树, 我们这里采用孩子表示法来表示二叉树, 因此需要先定义出节点, 同时给出根节点
public class BinarySearchTree {
// 节点定义
static class TreeNode{
int val;
TreeNode left;
TreeNode right;
TreeNode(int val){
this.val = val;
}
}
// 根节点
public TreeNode root;
}
同时, 我们依旧是手动创建一个二叉搜索树, 用于检测我们后续实现的一些方法. 刚开始我们依旧是通过手动连接节点的方式来进行, 我们就创建一棵如下图所示的二叉搜索树
public void createTree(){
root = new TreeNode(5);
root.left = new TreeNode(3);
root.right = new TreeNode(7);
root.left.left = new TreeNode(1);
root.left.right = new TreeNode(4);
root.right.left = new TreeNode(6);
root.right.right = new TreeNode(8);
root.left.left.left = new TreeNode(0);
root.left.left.right = new TreeNode(2);
root.right.right.right = new TreeNode(9);
}
查找
二叉搜索树既然名字里有搜索, 当然重要的用途之一就是用于查询数据, 因此我们最先来实现这个功能.
这个功能, 我们当然可以通过遍历实现, 但是这样就无法体现二叉搜索树结构的优势了, 我们刚开始说到对于一棵二叉搜索树来说, 根节点的左子树是小于根节点的, 而右子树是大于根节点的.
那么此时就有一个很简单的思路, 我们先判断一下要查找的值是不是当前根节点的值, 如果不是, 我们就去看看这个值是比它小还是比它大. 如果比根节点小, 那么就去往左子树走, 反之, 如果比根节点大, 就往右子树走.
当然这个思路还有一个细节问题, 就是可能会遇到空, 那么我们不妨想一下什么时候会遇到空呢? 假如我们想在上面我们创建好的二叉搜索树中找到 -1. 很明显会因为 -1 小于所有节点, 一直往左走
那么实际上对于树中不存在的值都是同理的, 因此如果一直搜索, 搜索到了空, 此时我们就可以判定这棵树中不存在这样的节点.
那么这个方法还是比较简单的, 下面是代码实现
public boolean search(int target){
// 从根开始遍历
TreeNode cur = root;
while(cur != null){
if(cur.val == target){
return true;
}else if(target > cur.val){
// 目标值大于当前节点, 右移
cur = cur.right;
}else{
// 目标值小于当前节点, 左移
cur = cur.left;
}
}
// 此时遇到 null 了, 证明没有找到
return false;
}
插入
要对一棵二叉搜索树进行节点的插入, 那就肯定不能破坏二叉搜索树的结构. 那既然不能破坏二叉搜索树本身的结构, 我们肯定就不能随便找一个位置就把节点放进去, 而是要找到一个合适的位置
那么这个合适的位置应该在哪呢? 很明显, 我们需要借助二叉搜索树的性质来找到这个位置.
例如下面这个树中, 我们想要插入 75, 那么很明显合适的位置就是在 70 的右边, 因为 75 > 70, 所以放在 70 的右边
那么如何找到这个位置, 也是很简单的, 就和我们查找的过程差不多, 只不过它是找到空就返回, 而这里是找到空则证明这是用于放节点的位置. 但是此时就引出了另一个位置, 找到了位置又要如何插入呢?
很明显, 对于采用链式结构存储的二叉树, 我们插入就需要和链表差不多, 需要知道前一个节点, 然后令前一个节点的 left 或 right 连接上这个新节点即可. 因此我们在遍历的过程中, 需要通过一个 parent 引用记录父亲节点.
下面是一个图示
但是此时还有个两个细节问题, 首先第一个问题就是如果值相等怎么办? 那么此时我们需要明白的一点是, 二叉搜索树是无法用于存储多个相同的节点的, 换句话说, 当出现相同值的时候, 此时就无法进行插入操作.
还有另一个问题就是, 如果树中一个节点都没有, 那没有 parent 如何进行插入? 实际上这个问题也很简单, 既然一个节点都没有, 我们直接放到根节点上即可
那么看了思路, 接下来就是代码实现
public boolean insert(int target){
if(root == null){
root = new TreeNode(target);
return true;
}
TreeNode cur = root;
TreeNode parent = null;
while(cur != null){
if(cur.val == target){
// 相等, 已经存在节点, 返回false
return false;
}else if(target > cur.val){
// 目标值大于当前节点, 右移
parent = cur;
cur = cur.right;
}else{
// 目标值小于当前节点, 左移
parent = cur;
cur = cur.left;
}
}
// 找到合适位置, 插入
if(parent.val > target){
parent.left = new TreeNode(target);
}else{
parent.right = new TreeNode(target);
}
return true;
}
删除
删除操作就和添加操作一样, 都是要找到父节点, 然后再进行操作, 那么此时看似似乎也是一个很简单的操作, 实际上并没有那么简单.
因为节点的下面可能会有其他节点, 那假如没有节点还好说, 直接删了没什么事. 有一棵树, 好像也不是很大的事, 直接把下面的树, 移上来就行. 如下图所示
但是对于有两个子树的, 那么就有一些难处理了, 因为我们不可能说直接把两个树拿上来直接接上去, 这样都直接变成三叉树了, 那此时又要怎么处理呢?
假如我下面这个情况要删除节点40, 就是非常难处理的一个情况了. 能直接把一个子树拉上来替换吗? 很明显也不太行, 假如我把 45 拉上来, 那么下面的 43 和 55 又要怎么处理呢?
那么既然直接删不行, 我们就可以变相的删. 回想一下我们当初删除堆的堆顶元素的时候, 也并非直接从堆顶删除, 而是将其与最后一个元素进行替换, 然后在进行 size 的改变以及向下调整来删除掉的. 那么对于这个二叉搜索树我们当然是无法做 size 的改变来删了, 毕竟不是数组而是节点, 但是交换一个元素的思路我们可以参考.
我们能否通过交换一个等效的节点, 同时交换的那个节点是非常好删的一个节点, 比如一个没有子树或者只有一棵子树的节点. 然后在交换后删除这个节点来达到删除的效果呢?
那么首先我们就需要找到这个等效的节点, 那么除了这个节点本身, 还有那些节点可以坐在这个节点的位置上呢? 实际上我们可以从一整棵树的角度来看, 毕竟每一个节点都自己组成了一棵二叉搜索树.
很明显, 对于下面这棵树来说, 除了 60 本身, 最适合坐在根节点位置上的节点, 就只有左子树最右侧的节点和右子树最左侧的节点. 因为这两个节点替换上来后, 都可以完美的符合大于左侧所有节点, 小于右侧所有节点的性质.
这个性质与他们原先所处的位置有关, 以左子树最右侧的节点为例:
- 它在左子树的最右侧, 证明它是左子树里最大的, 那么它放上来就可以满足大于所有左子树节点的性质.
- 同时由于它在左子树, 那么肯定是小于右子树的所有节点的, 那么放上来自然就可以满足小于所有右子树节点的性质.
右子树最左侧的节点也是同理的.
那么结合上面的推理, 我们就得到了最合适放上来的节点, 并且由于它是最边上的节点, 这还说明了一件事. 以左子树的最右侧节点为例, 既然都是最右侧的节点了, 那么就说明, 它一定没有右子树. 换句话说, 它又符合了我们的另一个要求, 最多只会有一个子树, 也就是它是一个便于删除的节点.
那么此时我们就可以总结一下思路了:
- 如果删除节点没有子树, 直接删
- 如果删除节点只有一个子树, 连接子树
- 如果删除节点有两个子树, 找到替代节点, 将其值放到删除节点上, 删除替代节点
另外有以下几个注意点:
- 第一个情况可以和第二个情况合并起来一起写, 因为连接子树当然也可以连接一个空的子树. 也可以分开使得代码可读性更高, 当然相应的代码也会更长
- 然后第二个情况虽然说是一种情况, 但是实际上分为了只有左侧和右侧, 因此代码中体现成两块.
- 第三个情况中, 有两种找替代节点的方法, 分别是左子树的最右侧和右子树的最左侧, 随意挑选一个即可
然后就是细节问题:
- 如果是根节点, 则没有parent, 需要特殊处理
- 对于替代节点的删除, 实际上就是情况一和情况二
那么下面就是代码实现
首先搭一个删除方法的框架, 就是查找, 如果找到了执行删除
public boolean delete(int target){
TreeNode parent = null;
TreeNode cur = root;
while(cur != null){
if(cur.val == target){
// 找到目标节点, 执行删除操作
deleteNode(parent, cur);
}else if(target > cur.val){
// 目标值大于当前节点, 右移
parent = cur;
cur = cur.right;
}else{
// 目标值小于当前节点, 左移
parent = cur;
cur = cur.left;
}
}
// 没找到
return false;
}
接下来就是删除的核心逻辑, 我们这里对于两边都不为空的替代节点寻找, 就采取找左子树的最右侧元素来实现
private void deleteNode(TreeNode parent, TreeNode cur) {
if(cur.left == null && cur.right == null){
// 左右都是空, 直接删除即可
// 根据有没有父亲节点, 来看看是不是根节点
if(parent == null){
root = null;
}else if(parent.left == cur){
// 是父亲节点的左节点, 直接删除
parent.left = null;
}else{
// 同上
parent.right = null;
}
}else if(cur.left == null && cur.right != null){
// 左子树为空, 右子树不为空, 让 parent 连接右子树即可
if(parent == null){
// 根节点处理
root = cur.right;
}else if(parent.left == cur){
// 删除节点是父亲的左节点, 连到左边
parent.left = cur.right;
}else{
// 删除节点是父亲的右节点, 连到右边
parent.right = cur.right;
}
}else if(cur.left != null && cur.right == null){
// 左子树不为空, 右子树为空, 让 parent 连接左子树即可
if(parent == null){
// 根节点处理
root = cur.left;
}else if(parent.left == cur){
// 删除节点是父亲的左节点, 连到左边
parent.left = cur.left;
}else{
// 删除节点是父亲的右节点, 连到右边
parent.right = cur.left;
}
}else{
// 两边都不为空, 找到替代节点(这里选择左子树的最右侧节点)
TreeNode replace = cur.left;
TreeNode replaceParent = cur;
// 找到最右侧节点
while(replace.right != null){
replaceParent = replace;
replace = replace.right;
}
// 找到后进行交换
cur.val = replace.val;
// 然后删除掉替代节点, 此时需要弄清 parent 和 cur 的关系, 和上面同理
if(replaceParent.left == replace){
// 删除节点是父亲的左节点, 连到左边
replaceParent.left = replace.left;
}else{
// 删除节点是父亲的右节点, 连到右边
replaceParent.right = replace.left;
}
}
}
可以发现, 我们在删除替代节点的时候, 就是把情况一和情况二统一了. 前面当然也是可以统一的, 处理后代码如下所示
private void deleteNode(TreeNode parent, TreeNode cur) {
if(cur.left == null){
// 左子树为空, 右子树不为空, 让 parent 连接右子树即可
if(parent == null){
// 根节点处理
root = cur.right;
}else if(parent.left == cur){
// 删除节点是父亲的左节点, 连到左边
parent.left = cur.right;
}else{
// 删除节点是父亲的右节点, 连到右边
parent.right = cur.right;
}
}else if(cur.right == null){
// 左子树不为空, 右子树为空, 让 parent 连接左子树即可
if(parent == null){
// 根节点处理
root = cur.left;
}else if(parent.left == cur){
// 删除节点是父亲的左节点, 连到左边
parent.left = cur.left;
}else{
// 删除节点是父亲的右节点, 连到右边
parent.right = cur.left;
}
}else{
// 两边都不为空, 找到替代节点(这里选择左子树的最右侧节点)
TreeNode replace = cur.left;
TreeNode replaceParent = cur;
// 找到最右侧节点
while(replace.right != null){
replaceParent = replace;
replace = replace.right;
}
// 找到后进行交换
cur.val = replace.val;
// 然后删除掉替代节点, 此时需要弄清 parent 和 cur 的关系, 和上面同理
if(replaceParent.left == replace){
// 删除节点是父亲的左节点, 连到左边
replaceParent.left = replace.left;
}else{
// 删除节点是父亲的右节点, 连到右边
replaceParent.right = replace.left;
}
}
}
甚至于我们可以将删除替代节点的操作通过调用这个删除节点方法本身来实现, 只需要将replace
和replaceParent
传入即可, 此时也不可能死递归, 因为我们已经保证了传入的节点是情况一或情况二, 不可能再到情况三重新调用函数, 实际上这个也不能完全称作是一个递归, 只不过是调用自身简化代码而已. 但是也就简化了 4 行代码, 这里就不演示了
总结
很明显, 对于二叉搜索树来说, 如果它树的分布是对半分布的, 如下图左侧的二叉树所示. 那么此时在查找过程中每经过一个节点都会排除一半的元素, 类似于二分查找, 因此理想情况下查找的时间复杂度是 O(logN). 但是如果是最坏情况, 二叉搜索树会是一棵单叉树, 如右图所示, 那么此时每一次都只能排除一个节点, 此时查找的时间复杂度就是 O(N) 了
为了解决这种问题, 我们就可以在二叉搜索树的基础上, 引入平衡机制, 在每一次插入节点的时候对树的结构修改, 保证树的高度不会差距过大. 此时这个树也有一个新的名字, 叫做平衡二叉树, 也被叫做 AVL 树. 此外, 更进一步的还有一种平衡二叉树叫做红黑树, Java 中的 TreeMap类和TreeSet类的底层就是一棵红黑树.
由于这两个数据结构相对来说还是比较难的, 因此我们这里就简单知道即可, 后续再进行深入的学习. 下面我们来了解一下 TreeMap 和 TreeSet
TreeMap 和 TreeSet
初步了解
TreeMap 和 TreeSet 分别是 Map 接口和 Set 接口下的实现, 它与 HashMap 和 HashSet 不同的是, 它的底层是一个红黑树, 因此它里面的数据就有了一些特殊的性质. 下面就列出一些性质:
- 由于实现了 Map 和 Set, 因此它们依旧要求 key 都是不能重复的.
- 插入, 删除和查找的时间复杂度是 O(logN) 的, 具体操作根据红黑树特性而定
- 它们的 Key 都是有序的, 也因如此, 放入的 Key 需要能够比较, 也就是需要实现 Comparable 接口, 或者是传入比较器.
下面我们就看看 TreeMap 和 TreeSet 的使用
TreeSet的使用
常用方法
实际上 TreeSet 的常用方法和 HashSet 是一致的, 毕竟它们都是实现了 Set 接口的. 只不过 TreeSet 要求传入的元素必须是可比较的.
返回值, 方法名, 参数 | 说明 |
---|---|
boolean add(E e) | 添加元素, 已经有了就不会添加 |
void clear() | 清空集合 |
boolean remove(Object o) | 删除 o 元素 |
void clear() | 清空 |
boolean contains(Object o) | 判断是否含有o元素 |
Iterator<E> iterator | 获取迭代器 |
int size() | 获取集合大小 |
boolean isEmpty() | 判断是否为空 |
Object[] toArray() | 转换为 Object 数组 |
boolean containsAll(Collection<?> c) | 判断是否包含集合中的所有元素 |
boolean addAll(Collection<? extends E> c) | 添加集合中的所有元素 |
下面是一个常用方法的使用例
public class TreeSetExample {
public static void main(String[] args) {
// 创建一个 TreeSet
TreeSet<String> treeSet = new TreeSet<>();
// 使用 add 方法添加元素
boolean added = treeSet.add("apple");
System.out.println("添加元素 apple,是否成功:" + added);
added = treeSet.add("banana");
System.out.println("添加元素 banana,是否成功:" + added);
added = treeSet.add("cherry");
System.out.println("添加元素 cherry,是否成功:" + added);
added = treeSet.add("apple");
System.out.println("再次添加已存在元素 apple,是否成功:" + added);
// 使用 size 方法获取集合大小
int size = treeSet.size();
System.out.println("集合大小为:" + size);
// 使用 contains 方法判断是否含有元素
boolean containsApple = treeSet.contains("apple");
System.out.println("集合中是否包含 apple:" + containsApple);
// 使用 remove 方法删除元素
boolean removed = treeSet.remove("banana");
System.out.println("删除元素 banana,是否成功:" + removed);
// 使用 isEmpty 方法判断是否为空
boolean empty = treeSet.isEmpty();
System.out.println("集合是否为空:" + empty);
// 使用 iterator 方法获取迭代器遍历集合
Iterator<String> iterator = treeSet.iterator();
System.out.print("遍历集合:");
while (iterator.hasNext()) {
System.out.print(iterator.next() + " ");
}
System.out.println();
// 使用 toArray 方法转换为 Object 数组
Object[] array = treeSet.toArray();
System.out.print("转换为数组后的内容:");
for (Object element : array) {
System.out.print(element + " ");
}
System.out.println();
// 创建另一个集合
TreeSet<String> anotherSet = new TreeSet<>();
anotherSet.add("date");
anotherSet.add("elderberry");
// 使用 containsAll 方法判断是否包含另一个集合中的所有元素
boolean containsAll = treeSet.containsAll(anotherSet);
System.out.println("集合是否包含另一个集合中的所有元素:" + containsAll);
// 使用 addAll 方法添加另一个集合中的所有元素
boolean addedAll = treeSet.addAll(anotherSet);
System.out.println("添加另一个集合中的所有元素,是否成功:" + addedAll);
// 再次获取集合大小
size = treeSet.size();
System.out.println("添加另一个集合后的集合大小为:" + size);
// 清空集合
treeSet.clear();
System.out.println("集合清空后大小为:" + treeSet.size());
}
}
TreeSet元素的取出
Set 元素的取出都是一个道理的, 无法直接取, 而是需要通过一些特殊的方法来取出, 下面是一些示例. 分别演示了使用迭代器, for-each循环, 数组转换和 List 转换取出元素
public class Main {
public static void main(String[] args) {
Set<String> set = new TreeSet<>();
set.add("apple");
set.add("banana");
set.add("cherry");
// 使用迭代器
Iterator<String> iterator = set.iterator();
while (iterator.hasNext()) {
String element = iterator.next();
System.out.println(element);
}
System.out.println("=================================");
// 使用增强for循环
for (String element : set) {
System.out.println(element);
}
System.out.println("=================================");
// 转换为数组
String[] array = set.toArray(new String[0]);
for (String element : array) {
System.out.println(element);
}
System.out.println("=================================");
// 或者转换为列表
List<String> list = new ArrayList<>(set);
for (String element : list) {
System.out.println(element);
}
}
}
同时我们可以发现, 取出的元素都是有序的, 而不是像 HashSet 一样无序的.
TreeMap的使用
常用方法
下面是 TreeMap 的常用方法, 和 HashMap 类似
返回值, 方法名, 参数 | 说明 |
---|---|
V get(Object key) | 返回 key 对应的 value |
V getOrDefault(Object key, V defaultValue) | 返回 key 对应的 value,key 不存在,返回默认值 |
V put(K key, V value) | 设置 key 对应的 value |
V remove(Object key) | 删除 key 对应的映射关系 |
replace(K key, V value) | 将 key 对应的 value 进行修改 |
Set<K> keySet() | 返回所有 key 的不重复集合 |
Collection<V> values() | 返回所有 value 的可重复集合 |
Set<Map.Entry<K, V>> entrySet() | 返回所有的 key-value 映射关系 |
boolean containsKey(Object key) | 判断是否包含 key |
boolean containsValue(Object value) | 判断是否包含 value |
同样的, 它也有 Entry 的实现用于维护 key-value 的关系, 同时也有一样的操作方法
返回值, 方法名, 参数 | 说明 |
---|---|
K getKey() | 返回 entry 中的 key |
V getValue() | 返回 entry 中的 value |
V setValue(V value) | 将键值对中的value替换为指定value |
下面同样是一些示例代码
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
public class TreeMapExample {
public static void main(String[] args) {
// 创建 TreeMap
TreeMap<String, Integer> treeMap = new TreeMap<>();
// 使用 put 方法设置键值对
treeMap.put("apple", 5);
treeMap.put("banana", 3);
treeMap.put("cherry", 7);
// 使用 get 方法获取值
Integer value = treeMap.get("banana");
System.out.println("banana 的值为:" + value);
// 使用 getOrDefault 方法获取值,如果键不存在返回默认值
Integer defaultValue = treeMap.getOrDefault("orange", 0);
System.out.println("orange 的值(默认值)为:" + defaultValue);
// 使用 containsKey 方法判断是否包含键
boolean hasAppleKey = treeMap.containsKey("apple");
System.out.println("是否包含 apple 键:" + hasAppleKey);
// 使用 containsValue 方法判断是否包含值
boolean hasValueThree = treeMap.containsValue(3);
System.out.println("是否包含值 3:" + hasValueThree);
// 使用 keySet 方法获取所有键的集合
Set<String> keys = treeMap.keySet();
System.out.println("所有的键:" + keys);
// 使用 values 方法获取所有值的集合
Collection<Integer> values = treeMap.values();
System.out.println("所有的值:" + values);
// 使用 entrySet 方法获取所有键值对的集合
Set<Map.Entry<String, Integer>> entries = treeMap.entrySet();
System.out.println("所有的键值对:");
for (Map.Entry<String, Integer> entry : entries) {
System.out.println(entry.getKey() + " -> " + entry.getValue());
}
// 使用 remove 方法删除键值对
Integer removedValue = treeMap.remove("cherry");
System.out.println("删除 cherry 后的值为:" + removedValue);
// 使用 replace 方法修改值
treeMap.replace("apple", 8);
System.out.println("修改 apple 的值后:" + treeMap.get("apple"));
// 使用 Entry 的方法
for (Map.Entry<String, Integer> entry : treeMap.entrySet()) {
String key = entry.getKey();
Integer oldValue = entry.getValue();
Integer newValue = oldValue + 1;
entry.setValue(newValue);
System.out.println("修改后的键值对:" + key + " -> " + newValue);
}
}
}
同样的, 我们也可以发现, 它是关于 key 有序的.