Map学习笔记——深入理解TreeMap

128 阅读10分钟

nick-fewings-WqO0As9Od8U-unsplash.jpg


TreeMap 也是我们日常开发中比较常用的容器,它具有排序的功能,先简单看下它的继承结构:

image.png

TreeMap 具有如下特点:

  1. TreeMap是一个双列集合,是Map的子类,底层由红黑树实现
  2. key不能为空(因为key要用来排序),value可以为空
  3. key要么实现了 Comparable 接口,要么创建TreeMap对象时,指定 Comparator 比较器
  4. 元素会默认按照key大小顺序排序
  5. key如果重复,新值覆盖旧值

在探究TreeMap之前,因为TreeMap的底层是红黑树,所以在看红黑树之前,我们先了解下二叉搜索树以及平衡二叉搜索树.

1.二叉搜索树

我们先了解下树的基本概念:
◼ 节点、根节点、父节点、子节点、兄弟节点
◼ 一棵树可以没有任何节点,称为空树
◼ 一棵树可以只有 1 个节点,也就是只有根节点
◼ 子树、左子树、右子树

◼ 节点的度(degree):子树的个数
◼ 树的度:所有节点度中的最大值
◼ 叶子节点(leaf):度为 0 的节点
◼ 非叶子节点:度不为 0 的节点

image.png

二叉树的特点:
◼ 每个节点的度最大为 2(最多拥有 2 棵子树)
◼ 左子树和右子树是有顺序的
◼ 即使某节点只有一棵子树,也要区分左右子树

image.png

二叉搜索树特点
◼ 任意一个节点的值都大于其左子树所有节点的值
◼ 任意一个节点的值都小于其右子树所有节点的值
◼ 它的左右子树也是一棵二叉搜索树
◼ 二叉搜索树可以大大提高搜索数据的效率
◼ 二叉搜索树存储的元素必须具备可比较性(比如 int、string, double)
◼ 如果是自定义类型,需要指定比较方式

image.png 查找过程:

  1. 从根节点开始,如果要查找的值等于根节点,直接返回
  2. 如果要查找的值小于根节点的值,则在左子树中递归查找
  3. 如果要查找的值大于根节点的值,则在右子树中递归查找

image.png

我们以查找13为例:

  1. 首先和根节点45比较,比45小,那么去根节点的左子树找
  2. 然后和12比较,比12大,然后去节点12的右子树找
  3. 然后和22比较,比22小,去22的左子树找
  4. 然后和13比较,和13相等,返回13

在线演示二叉搜索树



2. 平衡二叉搜索树

为了避免一个树出现"瘸子"的情况,在二叉搜索树的基础上产生了平衡二叉搜索树,具有如下特点:

  1. 每个节点的平衡因子只可能是 1、0、-1(绝对值 ≤ 1,如果超过 1,称之为“失衡”)
  2. 每个节点的左右子树高度差不超过 1

平衡因子(Balance Factor):某结点的左右子树的高度差

在线演示二叉搜索树和平衡二叉搜索树
动画演示AVL树(平衡二叉搜索树)

比如:往树中添加 [1, 2, 3, 4, 5, 6, 7] 个数据
二叉搜索树image.png

树已经退化成链表了

平衡二叉搜索树:

image.png

2.1 二叉搜索树的旋转

在构建平衡二叉树的时候,当有新的元素节点插入时,需要检查插入后是否破坏了平衡,如果破坏了平衡,则需要通过旋转来维持平衡。

2.1.1 左旋

左旋就是将失衡节点的右支往左拉,右子节点变成父节点,并把晋升之后多余的左子节点出让给降级节点的右子节点。

image.png

2.1.2 右旋

右旋就是将失衡节点的左支往右拉,左子节点变成了父节点,并把晋升之后多余的右子节点出让给降级节点的左子节点。

image.png

2.2 二叉树的四种失衡情况

2.2.1 左左(LL)情况:右旋转(单旋)

左左(LL): 节点P的左子树的左子树导致P失衡

image.png

10是失衡节点,以10为基准右旋,这样失衡节点(10)及失衡节点的右子树(15)降下来,失衡节点的左子树(7,4,8,5)升上去。然后把升上去的右子树(8)出让给降级后的左子树。

2.2.2 右右(RR)情况:左旋转(单旋)

右右(RR): 节点P的右子树的右子树导致节点P失衡

image.png

11是失衡节点,以11为基准左旋,这样失衡节点(11)以及失衡节点的左子树(9)降下来,失衡节点的右子树(13,12,15,19)升上去。然后把升上去的左子树(12)出让给降级后的右子树。

可以分成两个步骤看:

image.png

2.2.3 左右(LR)情况:先左旋(RR)再右旋(LL)——双旋

image.png

左右情况:先让失衡节点的左子树左旋(不用出让节点),然后再让失衡节点右旋(出让节点,升上去的节点10出让给降级后左节点)

2.2.4 右左(RL)情况:先右旋(LL)再左旋(RR)——双旋

image.png

右左情况:先让失衡节点的右子树右旋(需要出让节点:将升上去之后的右子节点14出让给降级下来的左子节点), 然后再按照失衡节点左旋(不用出让节点)。

总结:左左和右右情况,只需要旋转一次即可,左右和右左需要旋转两次。

3.红黑树

红黑树也是一种自平衡的二叉搜索树

红黑树的性质:

  1. 节点是 RED\color{red}{RED}或者 BLACK\color{black}{BLACK}
  2. 根节点是 BLACK\color{black}{BLACK}
  3. 叶子节点(外部节点,空节点)都是 BLACK\color{black}{BLACK}
  4. RED\color{red}{RED} 节点的子节点都是 BLACK\color{black}{BLACK} (RED\color{red}{RED} 节点的 parent 都是 BLACK\color{black}{BLACK})
  5. 从根节点到叶子节点的所有路径上不能有 2 个连续的 RED\color{red}{RED} 节点
  6. 从任一节点到叶子节点的所有路径都包含相同数目的 BLACK\color{black}{BLACK} 节点

image.png

注意:这里的叶子节点就是 null 节点,实际中我们并不会去管他。在实际开发中,我们可能就添加17, 33 这样的节点,至于它们下面的null 节点是不需要管的。

红黑树中所说的叶子节点和我们以前 BinarySerachTree , AVLTree 所说的叶子节点不一样,红黑树中的叶子节点是值这些假想的null节点。比如17 可能是AVL 树的叶子节点,但是在红黑树中,它下面的null才是叶子节点。

红黑树让那些度为0或者度为1的节点都变成度为2的节点,怎么变呢?就是添加 null 节点。这样就变成了 真二叉树


平衡二叉搜索树VS红黑树

平衡二叉搜索树:

  1. 平衡标准比较严格:每个左右子树的高度差不超过1
  2. 搜索、添加、删除都是 O(logn) 复杂度,其中添加仅需 O(1) 次旋转调整、删除最多需要 O(logn) 次旋转调整
  3. 100W个节点,AVL树最大树高28

红黑树

  1. 平衡标准比较宽松:没有一条路径会大于其他路径的2倍
  2. 搜索、添加、删除都是 O(logn) 复杂度,其中添加、删除都仅需 O(1) 次旋转调整
  3. 100W个节点,红黑树最大树高40

总结

  1. 搜索的次数远远大于插入和删除,选择AVL树;搜索、插入、删除次数几乎差不多,选择红黑树
  2. 相对于AVL树来说,红黑树牺牲了部分平衡性以换取插入/删除操作时少量的旋转操作,整体来说性能要优于AVL树
  3. 红黑树的平均统计性能优于AVL树,实际应用中更多选择使用红黑树

4.TreeMap源码分析

4.1 类结构

public class TreeMap<K,V>
    extends AbstractMap<K,V>
    implements NavigableMap<K,V>, Cloneable, java.io.Serializable
{
    /**
     * 比较器,通过构造器传入
     */
    private final Comparator<? super K> comparator;

    /**
     * 根节点
     */
    private transient Entry<K,V> root;

    /**
     * 元素个数
     */
    private transient int size = 0;

    /**
     * 集合都有的属性,表示修改次数
     */
    private transient int modCount = 0;

    /**
     * 空参构造器,没有指定比较器
     */
    public TreeMap() {
        comparator = null;
    }

    /**
     * 制定了比较器
     */
    public TreeMap(Comparator<? super K> comparator) {
        this.comparator = comparator;
    }
    
    //其他构造器
    //.........
    
    
     /**
     * 元素
     */
    static final class Entry<K,V> implements Map.Entry<K,V> {
        K key;
        V value;
        Entry<K,V> left; //左节点
        Entry<K,V> right;  //右节点
        Entry<K,V> parent;  //父节点
        boolean color = BLACK;  //节点颜色
        
        //.....其他方法
    }    
    
}    

TreeMap要想完成比较功能,必须满足以下要求:

  1. 要么通过构造器传入比较器java.util.Comparator接口
  2. 要么key实现了java.lang.Comparable接口

4.2 put()方法分析

测试数据:

Map<Integer, String> map = new TreeMap<>();
map.put(1005, "小王");
map.put(1001, "小明");
map.put(1003, "小刚");
map.put(1002, "小红");
map.put(1004, "小飞");
map.forEach((k, v) -> System.out.printf("%s = %s, ", k, v));

put源码分析:

public V put(K key, V value) {
    //将根节点赋给t
    Entry<K,V> t = root;
    //如果t是null,说明这是第一次put元素
    if (t == null) {
        /**
         * 自己和自己比较
         *   1.判断key是否为空,如果为空,则抛出空指针异常
         *   2.判断是否指定了Comparator比较器或者key是否实现了Comparable接口
         */
        compare(key, key); // type (and possibly null) check

        //创建根节点元素
        root = new Entry<>(key, value, null);
        size = 1;
        modCount++;
        return null;
    }
    //走下面的逻辑说明不是第一次put元素
    
    int cmp;
    Entry<K,V> parent;
    //如果通过构造器制定了比较器,那么就用比较器进行比较,否则就使用Comparable比较
    Comparator<? super K> cpr = comparator;
    if (cpr != null) {
        //请看下面Comparable的do-while分析,他们两个本质上是一样的逻辑
        do {
            parent = t;
            cmp = cpr.compare(key, t.key);
            if (cmp < 0)
                t = t.left;
            else if (cmp > 0)
                t = t.right;
            else
                return t.setValue(value);
        } while (t != null);
    }
    else {  //使用Comparable比较
        //如果key是null,抛出异常,说明TreeMap的key不允许为空
        if (key == null)
            throw new NullPointerException();
        @SuppressWarnings("unchecked")
            Comparable<? super K> k = (Comparable<? super K>) key;
            
        //整个do-while的逻辑就是在做一件事:那就是找到当前put节点的父节点    
        do {
            //找到根节点(第一次t是根节点,经过循环后会用子节点逐级覆盖)
            parent = t;
            //当前传入的key和根节点的key比较
            cmp = k.compareTo(t.key);
            //如果cmp < 0, 说明当前key 小于 t.key
            if (cmp < 0)
                t = t.left;
            //如果cmp > 0, 说明当前key 大于 t.key    
            else if (cmp > 0)
                t = t.right;
            //否则说明key的值一样,则用新值覆盖旧值    
            else
                return t.setValue(value);
        } while (t != null);
    }
    //创建新的节点元素,parent就是上面通过do-while找到的
    Entry<K,V> e = new Entry<>(key, value, parent);
    //如果 cmp < 0, 说明当前节点要放到父节点的左子节点
    if (cmp < 0)
        parent.left = e;
    //如果 cmp > 0, 说明当前节点要放到父节点的右子节点    
    else
        parent.right = e;
    //红黑树的旋转和染色!!!    
    fixAfterInsertion(e);
    size++;
    modCount++;
    return null;
}

在线演示红黑树

image.png

总结:

  1. TreeMap的key不允许为空,否则抛出空指针异常
  2. 整个do-while逻辑就是做一件事:找到当前put元素的父节点

4.2 get()方法分析

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

核心逻辑就是getEntry()方法:

final Entry<K,V> getEntry(Object key) {
    //如果我们通过构造器传入了比较器,那么就用传入的比较器进行比较
    if (comparator != null)
        return getEntryUsingComparator(key);
    //如果key是null, 则抛出一样    
    if (key == null)
        throw new NullPointerException();
    @SuppressWarnings("unchecked")
        //使用key的Comparable进行比较
        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)  //小于0就左边找
            p = p.left;
        else if (cmp > 0)  //大于0就右边找
            p = p.right;
        else
            return p;  //找到了
    }
    return null;
}

查找比较简单,就是按照二叉树的查找逻辑进行查找。

好了,关于TreeMap的介绍就到这里吧,其中关于红黑树的逻辑并没有详细说明,如果打算专门写一篇文章来记录红黑树。

5.总结

  1. TreeMap的 key 不允许为空
  2. TreeMap可以根据key实现排序,但必须满足以下两个条件之一:要么通过构造器制定Comparator比较器,要么key实现了Comparable接口,否则无法完成排序将报错
  3. get的时候也不能指定null作为key,否则将报错
  4. 底层是红黑树

限于作者水平,文中难免有错误之处,欢迎指正,勿喷,感谢感谢