在阅读本文之前,建议读者优先阅读专栏内前面的文章。
前言
本文主要介绍如何自行实现和使用Java中的map结构。
一、搜索树:
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树,若它的左子树不为空,则左子树上所有节点的值都小于根节点的值;若它的右子树不为空,则右子树上所有节点的值都大于根节点的值;它的左右子树也分别为二叉搜索树。

java
int[] array = {5,3,4,1,7,8,2,6,0,9};
我们先来看看如何在这个结构中进行查找:

可以看到,其实二叉搜索树的查找还是比较简单的。我们接下来一步一步去用代码尝试实现。要想实现它,我们首先需要先定义好这个结构,实际上这个二叉搜索树的结构和我们之前定义的树的结构是一样的,所以也是如下的代码:
java
public class BinarySearchTree {
static class TreeNode{
public int val;
public TreeNode left;
public TreeNode right;
public TreeNode(int val){
this.val = val;
}
}
}
好了我们现在知道树中节点结构是什么样的了。那我们接下来肯定是要先去创建一棵二叉搜索树,才能去查询。但是我们暂时先不去写创建的方法,我们还是先去想如何实现查找。其实相对来说这个查找是比较容易实现的。我们只需要不断去传节点,然后将传入的节点中的值和所找的值去对比,小了的话就传左儿子,大了的话就传右儿子,直到找到或者没有子树。读者可先自行实现,我给出我的代码:
java
public class BinarySearchTree {
static class TreeNode{
public int val;
public TreeNode left;
public TreeNode right;
public TreeNode(int val){
this.val = val;
}
}
public TreeNode root;
public TreeNode search(int key){
TreeNode cur = root;
while(cur != null){
if(cur.val == key){
return cur;
}else if(cur.val < key){
cur = cur.right;
}else{
cur = cur.left;
}
}
return null;
}
}
接下来我们思考一下如何去实现插入操作。其实插入的思路也很简单,就是按照二叉搜索树定义的规则去做就可以了。大了我们就往右走,小了我们就往左走,读者自行思考如何实现,我这里给出我的代码:
java
public void insert(int key) {
if(root == null){
root = new TreeNode(key);
return;
}
TreeNode cur = root;
TreeNode parent = null;
TreeNode node = new TreeNode(key);
while(cur != null){
parent = cur;
if(key < cur.val){
cur = cur.left;
}else if(key > cur.val){
cur = cur.right;
}else{
return;
}
}
if(key < parent.val){
parent.left = node;
}else if(key > parent.val){
parent.right = node;
}
}
接下来我们就可以再写段代码来验证一下我们上述代码是否正确:
java
public class Main {
public static void main(String[] args) {
BinarySearchTree bst = new BinarySearchTree();
bst.insert(10);
bst.insert(5);
bst.insert(15);
bst.insert(9);
BinarySearchTree.TreeNode ret = bst.search(15);
System.out.println(ret.val);
}
}
其运行结果如下:

可以看到我们这里其实已经很好验证了代码的正确性。我们再通过如下的图来将上面的过程可视化。


接下来是二叉搜索树中相对比较难的删除操作,因为它可能会出现多种情况。我们将其归为三种情况。 cur.left == null的时候,cur是root,则root = cur.right;cur不是root,cur是parent.left,则parent.left = cur.right;cur不是root,cur是parent.right,则parent.right = cur.right。
cur.right == null的时候,cur是root,则root = cur.left;cur不是root,cur是parent.left,则parent.left = cur.left, cur不是root,cur是parent.right,则parent.right = cur.left。
cur.left != null && cur.right != null的时候,需要使用替换法进行删除,即在它的右子树中寻找中序下的第一个结点(关键码最小),用它的值填补到被删除节点中,再来处理该结点的删除问题。这里相对来说有点绕,读者可以先按照自己的理解去实现代码,然后参考我的代码:
java
public void remove(int key){
TreeNode cur = root;
TreeNode parent = null;
while(cur != null){
if(cur.val < key){
parent = cur;
cur = cur.right;
}else if(cur.val > key){
parent = cur;
cur = cur.left;
}else{
removeNode(parent, cur);
return;
}
}
}
private void removeNode(TreeNode parent, TreeNode cur) {
if(cur.left == null){
if(cur == root){
root = cur.right;
}else if(cur == parent.left){
parent.left = cur.right;
}else{
parent.right = cur.right;
}
}else if(cur.right == null){
if(cur == root){
root = cur.left;
}else if(cur == parent.left){
parent.left = cur.left;
}else{
parent.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.left == target){
targetParent.left = target.right;
}else{
targetParent.right = target.right;
}
}
}
所以说插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:

最优情况下,二叉搜索树为完全二叉树,其平均比较次数为logN;最差情况下,二叉搜索树退化为单支树,其平均比较次数为N/2。如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插入关键码,都可以是二叉搜索树的性能最佳?答案是肯定的,这种优化就是我们后面要讲的AVL树,即二叉平衡树;还有对AVL树的优化,即红黑树。
实际上,TreeMap和TreeSet,即Java中利用搜索树实现的Map和Set;实际上用的是红黑树,而红黑树是一棵近似平衡的二叉搜索树,即在二叉搜索树的基础之上 + 颜色以及红黑树性质验证,关于红黑树的内容后序再进行讲解。
二、搜索:
map和set是一种专门用来进行搜索的容器或者数据结构,其搜索的效率与其具体的实例化子类有关。以前常见的搜索方式有直接遍历,时间复杂度为O(N),元素如果比较多效率会非常慢;二分查找,时间复杂度为O(logN),但搜索前必须要求序列是有序的。上述排序比较适合静态类型的查找,即一般不会对区间进行插入和删除操作了,而现实中的查找比如根据姓名查询考试成绩;通讯录,即根据姓名查询联系方式;不重复集合,即需要先搜索关键字是否已经在集合中。可能在查找时进行一些插入和删除的操作,即动态查找,那上述两种方式就不太适合了,本节介绍的map和set是一种适合动态查找的集合容器。
一般把搜索的数据称为关键字(Key),和关键字对应的称为值(Value),将其称之为Key-value的键值对,所以模型会有两种。第一种是纯key模型,比如有一个英文词典,快速查找一个单词是否在词典中;快速查找某个名字在不在通讯录中。第二种是Key-Value模型,比如统计文件中每个单词出现的次数,统计结果是每个单词都有与其对应的次数,<单词,单词出现的次数>;梁山好汉的江湖绰号,每个好汉都有自己的江湖绰号;而map中存储的就是key-value的键值对,Set中只存储了Key。
三、map的使用:
map的官方文档如下:Map (Java Platform SE 8 )

Map是一个接口类,该类没有继承自Collection,该类中存储的是<K,V>结构的键值对,并且K一定是唯一的,不能重复。

从上面的源码中我们可以看到,在Map中有一个Entry接口,这就说明我们在实现Map的时候,必须一起实现这个Entry接口。Map.Entry<K, V> 是Map内部实现的用来存放<key, value>键值对映射关系的内部类,该内部类中主要提供了<key, value>的获取,value的设置以及Key的比较方式。我们再看下TreeMap中的源码:

可以看到TreeMap中也是实现了Entry,并且有着自己独有的成员变量,比如说这个颜色。从这里,我们就稍微见识到了红黑树这个结构。除了这个成员变量之外,其他的与我们定义的其实相差不大。然后我们Entry接口中常用的方法有下面这些:
| 方法 | 解释 |
|---|---|
| K getKey() | 返回entry中的key |
| V getValue() | 返回entry中的value |
| V setValue(V value) | 将键值对中的value替换为指定value |
但是需要注意的是,Map.Entry<K,V>并没有提供设置Key的方法。除此之外,我们在Map中常用方法则如下表:
| 方法 | 解释 |
|---|---|
| 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对应的映射关系 |
| 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 |
我们一个一个来测试下这些方法。首先是get和put这两个方法。这就是最简单的增加和查找功能:
java
import java.util.TreeMap;
public class Main {
public static void main(String[] args) {
TreeMap<String, Integer> treeMap = new TreeMap<>();
treeMap.put("abc", 3);
treeMap.put("hello", 2);
treeMap.put("world", 5);
int val = treeMap.get("hello");
System.out.println(val);
}
}
其运行结果如下:

然后我们可以看下下面的getOrDefault方法,在看这个方法之前,我们可以先尝试运行如下的代码:
java
import java.util.TreeMap;
public class Main {
public static void main(String[] args) {
TreeMap<String, Integer> treeMap = new TreeMap<>();
treeMap.put("abc", 3);
treeMap.put("hello", 2);
treeMap.put("world", 5);
int val = treeMap.get("hello2");
System.out.println(val);
}
}
其运行结果如下:

可以看到我们此时会由于不存在hello2字段而导致出现异常抛出,此时因为你是null,导致我去拆箱的时候出错。所以我们可以作出如下的修改:
java
import java.util.TreeMap;
public class Main {
public static void main(String[] args) {
TreeMap<String, Integer> treeMap = new TreeMap<>();
treeMap.put("abc", 3);
treeMap.put("hello", 2);
treeMap.put("world", 5);
Integer val = treeMap.get("hello2");
System.out.println(val);
}
}
其运行结果如下:

但有的时候我们是想,如果说这个东西没找到的话,那我们就用一个默认的值来进行代表,这就会用到我们说的的这个方法了。
java
import java.util.TreeMap;
public class Main {
public static void main(String[] args) {
TreeMap<String, Integer> treeMap = new TreeMap<>();
treeMap.put("abc", 3);
treeMap.put("hello", 2);
treeMap.put("world", 5);
Integer val = treeMap.getOrDefault("hello2", 100);
System.out.println(val);
}
}
此时就是在去找这个字段,如果找到的话就输出对应value;没找到的话就输出我们规定的默认值,也就是1000。我们代码运行结果如下:

接下来我们来看看remove方法。它就是去除我们想要删掉的字段,我们键入如下的代码:
java
import java.util.TreeMap;
public class Main {
public static void main(String[] args) {
TreeMap<String, Integer> treeMap = new TreeMap<>();
treeMap.put("abc", 3);
treeMap.put("hello", 2);
treeMap.put("world", 5);
treeMap.remove("hello");
int val = treeMap.get("hello");
System.out.println(val);
}
}
其运行结果如下:

我们然后先说下最下面的两个方法,也就是是否包含key和value。实际上,这两个方法十分简单并且结果十分明显,所以我们不做演示。然后就是剩下的这三个方法,它们中有两个涉及到了我们之后会讲到的Set类。我们先来看看第一个方法:
java
import java.util.Set;
import java.util.TreeMap;
public class Main {
public static void main(String[] args) {
TreeMap<String, Integer> treeMap = new TreeMap<>();
treeMap.put("abc", 3);
treeMap.put("hello", 2);
treeMap.put("world", 5);
Set<String> set = treeMap.keySet();
}
}
它其实就只是把我们在Map中的数据都转到了Set之中。紧接着我们就可以去验证下面的方法,也就是直接在代码的末尾加上一行:
java
Collection<Integer> collection = treeMap.values();
这样我们的所有value值就都到了collection类实例化得到的对象中了。我们可以去看下这个方法的源码:
java
/**
* Returns a {@link Collection} view of the values contained in this map.
*
* <p>The collection's iterator returns the values in ascending order
* of the corresponding keys. The collection's spliterator is
* <em><a href="Spliterator.html#binding">late-binding</a></em>,
* <em>fail-fast</em>, and additionally reports {@link Spliterator#ORDERED}
* with an encounter order that is ascending order of the corresponding
* keys.
*
* <p>The collection is backed by the map, so changes to the map are
* reflected in the collection, and vice-versa. If the map is
* modified while an iteration over the collection is in progress
* (except through the iterator's own {@code remove} operation),
* the results of the iteration are undefined. The collection
* supports element removal, which removes the corresponding
* mapping from the map, via the {@code Iterator.remove},
* {@code Collection.remove}, {@code removeAll},
* {@code retainAll} and {@code clear} operations. It does not
* support the {@code add} or {@code addAll} operations.
*/
public Collection<V> values() {
Collection<V> vs = values;
if (vs == null) {
vs = new Values();
values = vs;
}
return vs;
}
虽然说可能我们不是看的很明白,但我们其实能注意到它是用一个叫vs的东西来接收的,并且这个vs就是在接收values。我们跳转到values变量的定义去看一下他的变量类型:
java
transient Collection<V> values;
所以其实我们最后返回的就的的确确是一个collection类型的数据。剩下的这个方法是最重要的一个,它会返回所有的映射关系,它实际上就是把我们treeMap上所有的节点都存储在了一个集合之中。我们键入如下的代码:
java
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
public class Main {
public static void main(String[] args) {
TreeMap<String, Integer> treeMap = new TreeMap<>();
treeMap.put("abc", 3);
treeMap.put("hello", 2);
treeMap.put("world", 5);
Set<Map.Entry<String, Integer>> entrySet = treeMap.entrySet();
for(Map.Entry<String, Integer> entry : entrySet) {
System.out.println(entry.getKey() + " : " + entry.getValue());
}
}
}
其运行结果如下:

在介绍完上述方法后,我们要注意一下如下的事项。 首先Map是一个接口,不能直接实例化对象,如果要实例化对象只能实例化其实现类TreeMap或者HashMap;其次Map中存放键值对的Key是唯一的,value是可以重复的,如果Key出现重复就会导致数据发生覆盖;并且在TreeMap中插入键值对时,key不能为空,否则就会抛NullPointerException异常,但是value可以为空,而且HashMap的key和value都可以为空;然后Map中的Key可以全部分离出来,存储到Set中来进行访问(因为Key不能重复);同理Map中的value可以全部分离出来,存储在Collection的任何一个子集合中(value可能有重复);最后是Map中键值对的Key不能直接修改,value可以修改,如果要修改key,只能先将该key删除掉,然后再来进行重新插入。
我们刚刚在上面提到了HashMap,那么接下来我们来简单介绍一下TreeMap和HashMap的区别,HashMap在之后会讲到:
| Map底层结构 | TreeMap | HashMap |
|---|---|---|
| 底层结构 | 红黑树 | 哈希桶 |
| 插入/删除/查找时间复杂度 | O(logN) | O(1) |
| 是否有序 | 关于 Key 有序 | 无序 |
| 线程安全 | 不安全 | 不安全 |
| 插入/删除/查找区别 | 需要进行元素比较 | 通过哈希函数计算哈希地址 |
| 比较与覆写 | key必须能够比较,否则会抛出ClassCastException异常 | 自定义类型需要覆写equals和hashCode方法 |
| 应用场景 | 需要Key有序场景下 | Key是否有序不关心,需要更高的时间性能 |
总结
本文详细介绍了Java中二叉搜索树和Map结构的实现与应用。主要内容包括二叉搜索树实现,定义树节点结构,实现查找、插入和删除操作分析不同情况下的时间复杂度(最优O(logN),最差O(N)),提出平衡二叉树的优化方向;Map接口及其实现类TreeMap,介绍Map的Key-Value模型,解析TreeMap中的Entry接口,演示常用方法put/get/remove/contains等,比较TreeMap和HashMap的区别;底层结构(红黑树vs哈希表),时间复杂度(O(logN)vs O(1)),有序性差异,线程安全性;使用建议,需要Key有序时选择TreeMap,追求性能时选择HashMap,自定义类型作为Key时的注意事项。文章通过代码示例详细讲解了二叉搜索树和TreeMap的实现原理及使用方法,为理解Java集合框架中的搜索结构提供了实践指导。