HashMap、LinkedHashMap、TreeMap

HashMap、LinkedHashMap、TreeMap

HashMap

  • 底层结构

    • 数组

    • 链表

      当链表的长度大于等于 8 时,链表会转化成红黑树;

    • 红黑树

      当红黑树的大小小于等于 6 时,红黑树会转化成链表。

  • 常见属性

  • 主要操作

    • 新增

      • 链表的新增

        链表的新增比较简单,就是把当前节点追加到链表的尾部,和 LinkedList 的追加实现一样的。

        链表长度大于等于8时转化成红黑树。

        链表查询的时间复杂度是 O (n),红黑树的查询复杂度是 O (log (n))。在链表数据不多的时候,使用链表进行遍历也比较快,只有当链表数据比较多的时候,才会转化成红黑树,但红黑树需要的占用空间是链表的 2 倍,考虑到转化时间和空间损耗,所以我们需要定义出转化的边界值。考虑设计 8 这个值的时候,参考了泊松分布概率函数,由泊松分布中得出结论,参考链表各个长度的命中概率,尤其是当链表的长度是 8 的时候,出现的概率是 0.00000006,不到千万分之一。所以说正常情况下,链表的长度不可能到达 8 ,而一旦到达 8 时,肯定是 hash 算法出了问题,所以在这种情况下,为了让 HashMap 仍然有较高的查询性能,所以让链表转化成红黑树。

      • 红黑树新增结点过程

        1. 首先判断新增的节点在红黑树上是不是已经存在,判断手段有如下两种:

          • 1.1. 如果节点没有实现 Comparable 接口,使用 equals 进行判断;
          • 1.2. 如果节点自己实现了 Comparable 接口,使用 compareTo 进行判断。
        2. 新增的节点如果已经在红黑树上,直接返回;不在的话,判断新增节点是在当前节点的左边还是右边,左边值小,右边值大;

        3. 自旋递归 1 和 2 步,直到当前节点的左边或者右边的节点为空时,停止自旋,当前节点即为我们新增节点的父节点;

        4. 把新增节点放到当前节点的左边或右边为空的地方,并于当前节点建立父子节点关系;

        5. 进行着色和旋转,结束。

        总结

        红黑树的新增,要求我们对红黑树的数据结构有一定的了解。但我们要清楚着色指的是给红黑树的节点着上红色或黑色,旋转是为了让红黑树更加平衡,提高查询的效率,总的来说都是为了满足红黑树的 5 个原则:(1)节点是红色或黑色;(2)根是黑色;(3)所有叶子都是黑色;(4)从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点;(5)从每个叶子到根的所有路径上不能有两个连续的红色节点;(6)空间换时间。

      • 扩容

        if (++size > threshold) resize();如果 HashMap 的实际大小大于扩容的门槛,就开始扩容

        • 计算扩容后大小:如果容量超过最大容量,则无法进行扩容;没有超过则扩大为原来的两倍。

        • 设置新的扩容阈值

        • 创建新的哈希表:遍历旧哈希表的每个桶,重新计算桶里元素的新位置。如果桶上只有一个键值对,就直接插入;如果是通过红黑树来处理冲突的,则调用相关方法把树分离开;如果采用链式处理冲突,就也是通过二次哈希的方式来计算节点的新位置。

    • 查找

    • 散列

      https://www.bbsmax.com/A/VGzlaMD8Jb/

  • 引申注意点

    • 哈希算法

    • equals方法和hashCode方法

      Object类当中hashcode方法的初衷是为了散列,而equals方法原本是比较通过内存地址来比较对象是否相等。如果为了比较对象逻辑上是否相同,才需要重写equals方法。而重写equals方法,如果不重写hashcode方法,逻辑上相同的对象会有不同的哈希值,会造成两个方法的矛盾。

      为什么重写equals还要重写hashCode:因为在某些容器比如Map中,对于Map来说管理键值对时判断两个键是否相同是既要考虑hash码是否相同,又要考虑equals方法比较是否返回为true,所以我们在写类的时候如果要重新equals方法就说明我们自己制定了一种规则来比较两个对象是否相等,为了让Map可以按照我们的规则来管理键值对,我们需要重写hashcode方法,让hashcode与equals方法逻辑保持一致。

      Map为什么既要比较hash又要使用equals方法比较呢:比如在HashMap当中插入键值对操作的时候会先判断是否有重复的键值,就是先比较hash值的再通过==比较内存地址或者equals方法比较,因为hash值在之前已经计算出了,在这个地方先比较这个值是否相等速度更快,如果hash值不等,根据&&的计算规则,后面的判断也就不用进行了;另外,hash值相等,equals比较还不一定相等。

      重写 equal() 时为什么也得重写 hashCode() 之深度解读 equal 方法与 hashCode 方法渊源

    • jdk1.7和jdk1.8的区别

  • 概括

    • 允许 null 值,不同于 HashTable ,是线程不安全的;
    • load factor(影响因子) 默认值是 0.75, 是均衡了时间和空间损耗算出来的值,较高的值会减少空间开销(扩容减少,数组大小增长速度变慢),但增加了查找成本(hash 冲突增加,链表长度变长),不扩容的条件:数组容量 > 需要的数组大小 /load factor;
    • 如果有很多数据需要储存到 HashMap 中,建议 HashMap 的容量一开始就设置成足够的大小,这样可以防止在其过程中不断的扩容,影响性能;
    • HashMap 是非线程安全的,我们可以自己在外部加锁,或者通过 Collections#synchronizedMap 来实现线程安全,Collections#synchronizedMap 的实现是在每个方法上加上了 synchronized 锁;
    • 在迭代过程中,如果 HashMap 的结构被修改,会快速失败。
  • 总结

    • HashMap 的内容较多,但大多数 api 是对数组 + 链表 + 红黑树这种数据结构进行封装。
    • 虽然HashMap设计非常巧妙,但是编码风格还是不要向HashMap学习。
//Java8
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    
    //默认的初始容量为 16 
	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
 
	//最大的容量上限为 2^30 
	static final int MAXIMUM_CAPACITY = 1 << 30; 
	 
	//默认的负载因子为 0.75 
	static final float DEFAULT_LOAD_FACTOR = 0.75f; 

 
	//变成树型结构的临界值为 8 ,桶上的链表长度大于等于8时,链表转化成红黑树
	static final int TREEIFY_THRESHOLD = 8; 
 
	//恢复链式结构的临界值为 6 ,桶上的红黑树大小小于等于6时,红黑树转化成链表
	static final int UNTREEIFY_THRESHOLD = 6; 
 
	//哈希表 
	transient Node<K,V>[] table; 
 
	//哈希表中键值对的个数 
	transient int size; 
 
	//哈希表被修改的次数 
	transient int modCount; 
	 
	//它是通过 capacity*load factor 计算出来的,当 size 到达这个值时,就会进行扩容操作 
	int threshold; 
 
	//负载因子 
	final float loadFactor; 
 
    //当数组容量大于这个阈值 64 时,链表才会转化成红黑树,否则仅采取扩容来尝试减少冲突
	static final int MIN_TREEIFY_CAPACITY = 64;
    
    //键值对的个数,HashMap 的实际大小,可能不准(因为当你拿到这个值的时候,可能又发生了变化)
    transient int size;
    
    transient Node<K,V>[] table;//哈希表,存放数据的数组
    
    
    //链表结点的定义和红黑树结点的定义都有
    /*
    链表结点Node 类的定义,它是 HashMap 中的一个静态内部类,哈希表中的每一个节点都是 Node 类型。
    我们可以看到,Node 类中有 4 个属性,其中除了 key 和 value 之外,还有 hash 和 next 两个属性
    hash 是用来存储 key 的哈希值的,next 是在构建链表时用来指向后继节点的。 
    */
    static class Node<K, V> implements Map.Entry<K, V> {
        final int hash;
        final K key;
        V value;
        Node<K, V> next;

        Node(int hash, K key, V value, Node<K, V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey() {
            return key;
        }

        public final V getValue() {
            return value;
        }

        public final String toString() {
            return key + "=" + valu e;
        }


        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(val ue);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?, ?> e = (Map.Entry<?, ?>) o;
                if (Objects.equals(key, e.getKey()) &&
                        Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }
    
    
    /*
    下面是一些重要方法
    */
    
    
    //get 方法主要调用的是 getNode 方法
    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //如果哈希表不为空 && key 对应的桶上不为空 
        if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
            //是否直接命中 
            if (first.hash == hash && // always check first n ode 
                    ((k = first.key) == key || (key != null && ke y.equals(k))))
            return first;
            //判断是否有后续节点 
            if ((e = first.next) != null) {
                //如果当前的桶是采用红黑树处理冲突,则调用红黑树的 get 方法去获取节点
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode (hash, key);
                //不是红黑树的话,那就是传统的链式结构了,通过循环的方法判断链中是否存在该 key
                do {
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }
    /*实现步骤大致如下: 
    1、通过 hash 值获取该 key 映射到的桶。 
    2、桶上的 key 就是要查找的 key,则直接命中。 
    3、桶上的 key 不是要查找的 key,则查看后续节点:  
    (1)如果后续节点是树节点,通过调用树的方法查找该 key。  
    (2)如果后续节点是链式节点,则通过循环遍历链查找该 key。
    */
    
    
    
    //put方法的具体实现也是在 putVal 方法中
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    final V putVal(int hash, K key, V value, boolean onlyIf Absent,
                   boolean evict) {
        Node<K, V>[] tab;
        Node<K, V> p;
        int n, i;
        //如果哈希表为空,则先创建一个哈希表 
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //如果当前桶没有碰撞冲突,则直接把键值对插入,完事 
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K, V> e;
            K k;
            //如果桶上节点的 key 与当前 key 重复,那你就是我要找的节点
            了
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            //如果是采用红黑树的方式处理冲突,则通过红黑树的 putTreeVal 方法去插入这个键值对 
            else if (p instanceof TreeNode)
                e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
                //否则就是传统的链式结构 
            else {
                //采用循环遍历的方式,判断链中是否有重复的 key 
                for (int binCount = 0; ; ++binCount) {
                    //到了链尾还没找到重复的 key,则说明 HashMap 没有包含该键
                    if ((e = p.next) == null) {
                        //创建一个新节点插入到尾部 
                        p.next = newNode(hash, key, value, nul l);

                        //如果链的长度大于 TREEIFY_THRESHOLD 这个临界值,则把链变为红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 
                            treeifyBin(tab, hash);
                        break;
                    }
                    //找到了重复的 key 
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //这里表示在上面的操作中找到了重复的键,所以这里把该键的值替换为新值
            if (e != null) { // existing mapping for key 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //判断是否需要进行扩容 
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    /* put 方法比较复杂,实现步骤大致如下: 
   1、先通过 hash 值计算出 key 映射到哪个桶。 
   2、如果桶上没有碰撞冲突,则直接插入。 
   3、如果出现碰撞冲突了,则需要处理冲突:  
   (1)如果该桶使用红黑树处理冲突,则调用红黑树的方法插入。  
   (2)否则采用传统的链式方法插入。如果链的长度到达临界值,则把链转变为红黑树。
   4、如果桶中存在重复的键,则为该键替换新值。 
   5、如果 size 大于阈值,则进行扩容。
   */
    
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //当数组容量大于这个阈值MIN_TREEIFY_CAPACITY 64 时,
        //链表才会转化成红黑树,否则仅采取扩容来尝试减少冲突
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }
    
    
    //remove 方法的具体实现在 removeNode 方法中
    public V remove(Object key) {
        Node<K, V> e;
        return (e = removeNode(hash(key), key, null, false, tru e)) == null ?
                null : e.value;
    }

    final Node<K, V> removeNode(int hash, Object key, Object va lue,
                                boolean matchValue, boolean movabl e) {
        Node<K, V>[] tab;
        Node<K, V> p;
        int n, index;
        //如果当前 key 映射到的桶不为空 
        if ((tab = table) != null && (n = tab.length) > 0 &&
                (p = tab[index = (n - 1) & hash]) != null) {
            Node<K, V> node = null, e;
            K k;
            V v;
            //如果桶上的节点就是要找的 key,则直接命中 
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                //如果是以红黑树处理冲突,则构建一个树节点 
                if (p instanceof TreeNode)
                    node = ((TreeNode<K, V>) p).getTreeNode(hash, key);
                    //如果是以链式的方式处理冲突,则通过遍历链表来寻找节点 
                else {
                    do {
                        if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            //比对找到的 key 的 value 跟要删除的是否匹配 
            if (node != null && (!matchValue || (v = node.value) == value ||
                    (value != null && value.equals(v)))) {
                //通过调用红黑树的方法来删除节点 
                if (node instanceof TreeNode)
                    ((TreeNode<K, V>) node).removeTreeNode(this, t ab, movable);
                    //使用链表的操作来删除节点 
                else if (node == p)
                    tab[index] = node.next;
                else
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }
    
   
    //在get方法和put方法中都需要先计算key映射到哪个桶上,然后才进行之后的操作
    //映射到哪个桶上就是通过(n - 1) & hash, n 指的是哈希表的大小,hash 指的是 key 的哈希值
    //上面所得到的hash值都是用下面这个方法获得的
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        //采用了二次哈希的方式,其中 key 的 hashCode 方法是一个 native 方法
    }
    /*hash 方法先通过 key 的 hashCode 方法获取一个哈希值,
    再拿这个哈希值与它的高 16 位的哈希值做一个异或操作来得到最后的哈希值,
    注释中是这样解释的:如果当 n 很小,假设为 64 的话,那么 n-1 即为 63(0x111111),
    这样的值跟 hashCode()直接做与操作,实际上只使用了哈希值的后 6 位。
    如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成冲突,
    所以这里把高低位都利用起来,从而解决了这个问题。
    */
    
    
    //扩容
    //插入的时候,哈希表为空,或者是超过了扩容阈值
    //或者链表转成红黑树的过程,判断出数组容量没有超过转红黑树的阈值64,则优先扩容
    final Node<K, V>[] resize() {
        Node<K, V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        //计算扩容后的大小 
        if (oldCap > 0) {
            //如果当前容量超过最大容量,则无法进行扩容 
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //没超过最大值则扩为原来的两倍 
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
            oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold 
        } else if (oldThr > 0) // initial capacity was placed in t hreshold 
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults 
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIA L_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float) newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) M AXIMUM_CAPACITY ?
                    (int) ft : Integer.MAX_VALUE);
        }
        //新的 resize 阈值 
        threshold = newThr;
        //创建新的哈希表 
        @SuppressWarnings({"rawtypes", "unchecked"})
        Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            //遍历旧哈希表的每个桶,重新计算桶里元素的新位置 
            for (int j = 0; j < oldCap; ++j) {
                Node<K, V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    //如果桶上只有一个键值对,则直接插入 
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    //如果是通过红黑树来处理冲突的,则调用相关方法把树分离开 
                else if (e instanceof TreeNode)
                        ((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
                        //如果采用链式处理冲突 
                    else { // preserve order 
                        Node<K, V> loHead = null, loTail = null;
                        Node<K, V> hiHead = null, hiTail = null;
                        Node<K, V> next;
                        //通过上面讲的方法来计算节点的新位置 
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            } else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
    /* HashMap 在进行扩容时,使用的 rehash 方式非常巧妙,
   因为每次扩容都是翻倍,与原来计算(n-1)&hash 的结果相比,只是多了一个 bit 位,
   所以节点要么就在原来位置,要么就被分配到“原位置+旧容量”这个位置。
   正是因为这样巧妙的 rehash 方式,保证了 rehash 之后每个桶上的节点数必定小于等于原来桶上的节点数,
   即保证了 rehash 之后不会出现更严重的冲突。
   */
    //这些操作,也决定了HashMap的大小只能够是2的幂次方。

    
    
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
    
    /*如果不是2的幂次方,即是创建HashMap的时候指定了初始大小,
    HashMap在构建的时候也会调用tableSizeFor(int cap)来调整大小,
    这个方法实际作用也就是把cap变成一个等于2的幂次方的数。
    */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    
}

LinkedHashMap

文章


TreeMap

文章

使用TreeMap

深入理解HashMap和TreeMap的区别

关于TreeMap的排序

  • key需要实现Comparable接口;如果key没有实现Comparable接口,就需要通过设定比较器Comparator,实现compare方法来自定规则排序。
  • TreeMap不使用equals()hashCode(),TreeMap在比较两个Key是否相等时,依赖Key的compareTo()方法或者Comparator.compare()方法。
class Test{
    public void test(){
        //key已经实现了Comparable接口
        Map<String, Integer> map = new TreeMap<>();
        map.put("orange", 1);
        map.put("apple", 2);
        map.put("pear", 3);
        
        //作为Key的class没有实现Comparable接口,那必须在创建TreeMap时同时指定一个自定义排序算法
        Map<Person, Integer> map1 = new TreeMap<>(new Comparator<Person>() {
            public int compare(Person p1, Person p2) {
                return p1.name.compareTo(p2.name);
            }
        });
        map1.put(new Person("Tom"), 1);
        map1.put(new Person("Bob"), 2);
        map1.put(new Person("Lily"), 3);
        
        //TreeMap在比较两个Key是否相等时,
        //依赖Key的compareTo()方法或者Comparator.compare()方法
        Map<Student, Integer> map2 = new TreeMap<>(new Comparator<Student>() {
            public int compare(Student p1, Student p2) {
                return p1.score > p2.score ? -1 : 1;
            }
        });
        map2.put(new Student("Tom", 77), 1);
        map2.put(new Student("Bob", 66), 2);
        map2.put(new Student("Lily", 99), 3);
        System.out.println(map2.get(new Student("Bob", 66))); // 输出null,
        //因为上面的比较当中没有==这个比较
    }
}

TreeMap和HashMap的区别

  • 存储结构

    HashMap的存储是通过数组、链表、红黑树实现。

    TreeMap的实现就是红黑树。

  • 排序

    HashMap的输出结果不定。

    TreeMap输出的结果是排好序的。

  • null值处理

    HashMap允许key为null。

    TreeMap不允许key为null。

  • 性能

    HashMap由于通过散列映射到桶,所以在添加、查找、删除会比较快。

    TreeMap在添加、删除过程中会进行排序,对性能有一定影响。

  • 其他

    HashMap的key需要重写equals方法和hashCode方法。

    TreeMap的key不需要。

posted @ 2020-05-06 01:14  甜树果子二号  阅读(191)  评论(0编辑  收藏  举报