Java实现map

在阅读本文之前,建议读者优先阅读专栏内前面的文章。


前言

本文主要介绍如何自行实现和使用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集合框架中的搜索结构提供了实践指导。