Java容器之TreeMap源码解析

注意:我是先写的公众号文章,然后拷贝过来的,但是拷贝过来后样式很多都乱了,有些文字写的命令也变成了图片,如果需要更好的阅读体验,可以查看我的公众号文章:https://mp.weixin.qq.com/s/fJuHl5Z2WAAW6ruYp88urg

目录:

  1. TreeMap简介

  2. TreeMap实现的接口

  3. 构造方法源码分析

  4. compare和equals问题

  5. 红黑树原理概述

  6. put源码分析

  7. get源码分析

  8. remove源码分析

  9. iterator源码分析

  10. TreeMap性能分析

  11. 总结

 

1.    TreeMap简介

      TreeMap是Java集合框架中的一员,基于红黑树实现Map的相关功能。特点是可以根据Key的“自然顺序”或根据在创建时传入指定的Comparator进行排序。Java实现TreeMap所依据的红黑树理论来自于《算法导论》(作者是Cormen,Leiserson和Rivest)。这里所说的“自然顺序”指的是当没有指定Comparator,但是条目的数据类型实现了Comparable接口时,会使用该数据类型自身的比较方法进行排序。相比LinkedHashMap,TreeMap的排序更加灵活,因为LinkedHashMap只能根据插入顺序或者访问顺序进行排序。但使用红黑树的TreeMap占用的存储空间更大,效率也会比LinkedHashMap低。之所以使用红黑树来实现,主要的原因是红黑树基于查询二叉树,因此查询效率高,并且红黑树的在get、put、remove和containsKey操作时的时间复杂度都是Log(n)——时间复杂度的概念在讲解LinkedList的时候讲过,不再赘述。这样能弥补一些TreeMap的性能问题。

 

2.    TreeMap实现的接口

       TreeMap实现了NavigableMap、Cloneable和java.io.Serializable接口。如下图所示:

       这里重点关注NavigableMap和SortedMap。在SortedMap中最重要的一个方法是comparator(),它返回一个Comparator对象,用于在TreeMap中比较条目大小。另一个重点关注的是NavigableMap,这是一个比较有意思的接口,它里面的方法大部分是给定一个key,然后返回比这个key大的条目,或者比这个key小的条目。并且可以正序或倒序遍历map,不过根据官方文档正序的效率要比倒序高。

 

3.    构造方法源码分析

     TreeMap有4个构造函数,无参构造函数排序时使用“自然顺序”;Comparator参数的的构造函数用于使用指定的比较器进行排序;Map参数会在创建时,将原Map条目根据“自然顺序”进行排序;SortedMap参数的构造函数会保持原SortedMap的条目顺序和比较器。

      从构造函数可以看出,TreeMap并没有类似HashMap中的那些性能参数,比如初始容量或装载因子。所以也暗示它内部并没有类似数组的存储结构。

 

4.     compare和equals问题

      这里说的是一个有趣的问题。我们都知道,Map中的key是不能重复的,当向map中put一个已经存在的key时,后值会覆盖前值。Map在判断key的时候,使用的是key的equals方法。当TreeMap使用比较器的compare方法进行处理时,如果equals和compare不一致就会引发问题。如:向Map中添加a和b两个条目,其中a和b满足!a.equals(b)且a.compareTo(b) == 0,也就是说,equals方法说它俩不一样,但compare方法说它们是一样的。这时会怎么样呢?聪明的TreeMap会舍弃equals方法,也就是说,compare方法说它们一样它们就一样,因此条目b的value会覆盖条目a,而map的size不会变化。因此,为了避免不必要的麻烦,开发中一定要保证两个方法能够表达共同的语义,把持一致。

 

5.     红黑树原理概述

      要想说清楚TreeMap的处理逻辑,不得不说红黑树。之前在讲HashMap的时候,我们提过红黑树,但没有细讲,原因是我认为算法和数据结构是另一个领域,不希望在讲Java的时候过多的说它们,以免喧宾夺主。但是TreeMap整体就是围绕红黑树处理的,因此不讲不行了。但这里也只会讲大致的原理,不会深入到各种细节判断中。

       如果让我给红黑树起一个全名,我会叫它“红黑平衡二叉搜索树”,分解开来,这里面有二叉树、二叉搜索树、平衡二叉树和红黑树,我们一个个来看:

  • 二叉树:通俗来讲,就是一个树节点最多有两个子节点,也就是说一个树干最多两个叉叉。左边的叉叉叫做左子树,右边的叉叉叫做右子树,每个叉叉再分裂出子树,在树上就多了一个层级,这个节点所拥有的层级叫做这个节点的高度。

  • 二叉搜索树:大学的时候学过C语言的应该都听过一个叫做二分查找法的算法,或者也叫做折半查找法。当需要在一堆数字中找到目标数字的时候,可以先对这堆数字进行排序,然后看最中间的那个数字和目标数字谁大,以此确定目标数字是在上半区还是在下半区(我们在讲LinkedList的node(int index)方法的时候,有类似的代码处理)。判断好上半区还是下半区后,再在对应的分区中找最中间的那个数字跟目标数字比,这样循环往复,很快就可以找到目标数字了。二叉搜索树也是这样的,它规定,节点的所以左子树都标比节点小,而右子树都要比节点大。这样查询的时候只要目标数字先跟节点比,然后就知道该去左子树查还是右子树查了。

  • 平衡二叉树:二叉搜索树有一个缺点,就是有的节点的子树可能比较少,但是另一些子树会很多,这样的查询很不均衡,因此就诞生了平衡二叉树,平衡二叉树要求任意节点的子树的高度差都小于等于1,这就是为什么叫平衡二叉树的原因。

  • 红黑树:红黑树是平衡二叉树的一个变体,它并不严格要求高度小于等于1,主要是因为要兼顾增删改查算法的时间复杂度。它保持平衡的方法主要是通过对节点进行着色,通过颜色约束节点的高度,当颜色不符合规定时,通过左旋或右旋调整树结构,然后重新着色。下面是红黑树的颜色规则:1)节点是红色或黑色;2)根节点是黑色;3)所有叶子都是黑色;4)每个红色节点的两个子节点都是黑色;5)从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。一般满足前四点,第五点基本都能满足,所以第五点的意义在于校验,当不满足第五点,就需要通过左旋或右旋调整结构后重新根据前四点约束着色了。下图展示了左旋和右旋(图片引用自网络):

6.    put源码分析

Entry<K,V> t = root;if (t == null) {  compare(key, key); // type (and possibly null) check
  root = new Entry<>(key, value, null);  size = 1;  modCount++;  return null;}

       put方法的开头,先判断root是否为空,如果是空,就直接创建一个Entry对象赋值给root。第三行使用compare方法的含义在后面的注释中可以看到,是用于类型检查和空值检查的。

       这里我们来看一下Entry的源码:

K key;V value;Entry<K,V> left;Entry<K,V> right;Entry<K,V> parent;boolean color = BLACK;

        Entry类是TreeMap中的一个静态内部类,它的内部变量如上。根据《算法导论》,每个节点需要包含左树、右树、父级和颜色。这里的Entry就是对应的实现。

        源码中接下来的操作就是判断有没有传入指定的比较器,没有传入就用数据自己的compare方法。​​​​​​​

do {  parent = t;  cmp = k.compareTo(t.key);  if (cmp < 0)    t = t.left;  else if (cmp > 0)    t = t.right;  else    return t.setValue(value);} while (t != null);

      上面这段代码是找到要插入的条目应该在树中哪个节点下,也就是找parent。​​​​​​​

Entry<K,V> e = new Entry<>(key, value, parent);if (cmp < 0)    parent.left = e;else    parent.right = e;

       找到parent后,就创建一个Entry,然后挂在对应的parent下。cmp表示应该挂在左树还是右树。​​​​​​​

fixAfterInsertion(e);size++;modCount++;

       节点挂载好后,就使用fixAfterInsertion方法对树中节点的颜色进行调整。调整的逻辑在《算法导论》中已经写的很清楚了(伪代码),有兴趣的朋友可以去仔细看看,判断条件和细节处理还是比较多的。

       这样处理后,元素就添加到TreeMap中了。

 

7.     get源码分析​​​​​​​

public V get(Object key) {    Entry<K,V> p = getEntry(key);    return (p==null ? null : p.value);}

      get方法会调用getEntry方法。​​​​​​​

final Entry<K,V> getEntry(Object key) {    // Offload comparator-based version for sake of performance    if (comparator != null)        return getEntryUsingComparator(key);    if (key == null)        throw new NullPointerException();    @SuppressWarnings("unchecked")        Comparable<? super K> k = (Comparable<? super K>) key;    Entry<K,V> p = root;    while (p != null) {        int cmp = k.compareTo(p.key);        if (cmp < 0)            p = p.left;        else if (cmp > 0)            p = p.right;        else            return p;    }    return null;}

       如果传入了指定的比较器,则会调用getEntryUsingComparator方法,否则执行后续逻辑,实际上,getEntryUsingComparator的逻辑和后面这段代码的逻辑是一样的,注释中说将比较器的方法单独拆到getEntryUsingComparator中是为了性能,在getEntryUsingComparator方法的注释上说是因为这个方法依赖比较器的性能,所以拆出来,但是我确实没看出来这样做的好处在哪里,个人感觉拆出来也对性能没啥影响啊?

       上面while代码块就是使用类似二分查找法的方式去找目标key。

 

8.    remove源码分析​​​​​​​

public V remove(Object key) {    Entry<K,V> p = getEntry(key);    if (p == null)        return null;
    V oldValue = p.value;    deleteEntry(p);    return oldValue;}

        remove方法先使用上面讲过的getEntry方法获取key对应的条目,如果没获取到就返回null,否则将旧值保存到oldValue中,然后执行deleteEntry方法,执行后返回oldValue。​​​​​​​

private void deleteEntry(Entry<K,V> p) {      modCount++;      size--;
      // If strictly internal, copy successor's element to p and then make p      // point to successor.      if (p.left != null && p.right != null) {          Entry<K,V> s = successor(p);          p.key = s.key;          p.value = s.value;          p = s;      } // p has 2 children

        deleteEntry方法的开始先对modCount进行加一,表示发生了结构性变化,然后对size进行减一。

        下来的if块判断要删除的条目是否既有左树又有右树,如果条件成立,successor方法返回的是从当前条目的左树和右树中查找到的比当前条目值大的下一个条目,由于二叉搜索树的特点,这里的下一个条目一定是当前条目右树的最下层左树,如果没有左树就是当前条目的第一层右树。第九行到第十一行需要特别注意,否则可能会理解错误。第九行和第十行的p表示的是当前的条目,相当于不删除当前的树节点,只是把当前的树节点的key和value修改为下一条目的key和value,其它的属性保持不变(比如parent、left、right和color),这样做的好处是不用大面积的调整树节点的相互关系。第十一行将s赋值给p,因此从第11行开始,这里的p就是下一个条目了。​​​​​​​

// Start fixup at replacement node, if it exists.Entry<K,V> replacement = (p.left != null ? p.left : p.right);
if (replacement != null) {    // Link replacement to parent    replacement.parent = p.parent;    if (p.parent == null)        root = replacement;    else if (p == p.parent.left)        p.parent.left  = replacement;    else        p.parent.right = replacement;
    // Null out links so they are OK to use by fixAfterDeletion.    p.left = p.right = p.parent = null;
    // Fix replacement    if (p.color == BLACK)        fixAfterDeletion(replacement);}

        根据上一段中,下一个条目一定是当前条目右树的最下层左树,如果没有左树就是当前条目的第一层右树的结论可以看出,下一个条目不可能既拥有左树又拥有右树,因此上面代码一开始就将下一个条目的唯一一个子树赋值给replacement,当然,还有一种可能就是既没有左树也没有右树,此时replacement就是null。如果replacement不为空,就需要把这个元素挂载到下一个条目的parent,也就是修改了key和value的原条目上,挂好后,就可以将p所代表的下一个条目的left、right和parent属性设置为null,也就是没有引用关系了,垃圾回收启动的时候就可以回收掉了。第18行,如果p所代表的下一个条目的颜色是黑色,则需要对相关节点的颜色进行调整,因为黑色意味着它的子树颜色可能是红也可能是黑,挂到原条目上后,颜色规则就可能会被打破。调整颜色的fixAfterDeletion的代码来源于《算法导论》。

        这样操作后,元素就被删除掉了。

 

9.     iterator源码分析

        我们主要看一下EntryIterator,其他的迭代器是类似的。​​​​​​​

class EntrySet extends AbstractSet<Map.Entry<K,V>> {    public Iterator<Map.Entry<K,V>> iterator() {        return new EntryIterator(getFirstEntry());    }

        在EntrySet的iterator实现中,创建了EntryIterator,传入getFirstEntry()。​​​​​​​

final Entry<K,V> getFirstEntry() {    Entry<K,V> p = root;    if (p != null)        while (p.left != null)            p = p.left;    return p;}

        getFirstEntry的作用是返回最末级的左树,也就是整个树中最小的一个值的条目。​​​​​​​

final class EntryIterator extends PrivateEntryIterator<Map.Entry<K,V>> {    EntryIterator(Entry<K,V> first) {        super(first);    }    public Map.Entry<K,V> next() {        return nextEntry();    }}

        在使用循环对Map中的所有Entry进行访问的时候,会调用上面的next方法,进而调用nextEntry方法。​​​​​​​

final Entry<K,V> nextEntry() {    Entry<K,V> e = next;    if (e == null)        throw new NoSuchElementException();    if (modCount != expectedModCount)        throw new ConcurrentModificationException();    next = successor(e);    lastReturned = e;    return e;}

        nextEntry方法最终会调用successor方法返回比当前值大的下一个值的条目,这个方法的功能在上面也提到过,这里看看具体的源码:​​​​​​​

static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {    if (t == null)        return null;    else if (t.right != null) {        Entry<K,V> p = t.right;        while (p.left != null)            p = p.left;        return p;    } else {        Entry<K,V> p = t.parent;        Entry<K,V> ch = t;        while (p != null && ch == p.right) {            ch = p;            p = p.parent;        }        return p;    }}

        第四行的判断是看当前节点有没有右值,如果右,则下一个比当前值大的就是右值的最后一级左值;否则如果没有右值,那么比它大的一定在上级中。

 

10.   TreeMap性能分析

        在StackOverflow上,有一幅图,如下:

        这张图中对比了HashMap、TreeMap和LinkedHashMap,可以看到在时间复杂度一栏中,TreeMap为O(log(n))其它都是O(1),也就是说TreeMap的增删改查方法的时间复杂度要大于其他两个,除此之外,由于HashMap和LinkedHashMap内部的数据结构都是数组,而TreeMap内部对象是离散的,因此TreeMap的性能理论上应该远低于其它两个。那么能低多少呢?请看下面的图:

        上面这张图来源于csdn中的一篇文章。虽然我没有验证,不过应该也大差不差了。可以看出,TreeMap和HashMap的对比中,性能要慢2到3倍。在讲LinkedHashMap的时候我们分析过,LinkedHashMap的性能和HashMap近似,甚至有些情况可能会优于HashMap,因此可以推导出TreeMap比LinkedHashMap也会慢2到3倍。

 

11.    总结

        红黑树增删、左旋右旋和着色的代码,大家不用过于关注,因为这些代码在《算法导论》中已经给出了,而且充分考虑了各种情况,因此我们不需要太了解,毕竟我们本次的主题并不是算法,感兴趣的朋友可以自行研究。

        在日常使用中,如果需要对Map进行排序,应该优先考虑LinkedHashMap,如果不能满足需求,再考虑使用TreeMap,并且要明白TreeMap的性能会比HashMap和LinkedHashMap慢2-3倍左右。

        在我的计划中,后面会有算法的专题,到时候再仔细讲解一下红黑树的详细算法。

更多内容请关注我的公众号:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值