HashMap原理(一) 概念和底层架构

HashMap在Java开发中使用的非常频繁,可以说仅次于String,可以和ArrayList并驾齐驱,准备用几个章节来梳理一下HashMap。我们还是从定义一个HashMap开始。

HashMap<String, Integer> mapData = new HashMap<>();

我们从此处进入源码,逐步揭露HashMap

/**
 * Constructs an empty <tt>HashMap</tt> with the default initial capacity
 * (16) and the default load factor (0.75).
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

我们发现了两个变量loadFactor和DEFAULT_LOAD_FACTOR,从命名方式来看:因为没有接收到loadFactor参数,从而将某个默认值赋值给了loadFactor。这两变量到底是什么意思,还有无其他变量?

其实HashMap中定义的静态变量和成员变量很多,我们看一下

//静态变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

static final int MAXIMUM_CAPACITY = 1 << 30;

static final float DEFAULT_LOAD_FACTOR = 0.75f;

static final int TREEIFY_THRESHOLD = 8;

static final int UNTREEIFY_THRESHOLD = 6;

static final int MIN_TREEIFY_CAPACITY = 64;

//成员变量
transient Node<K,V>[] table;

transient Set<Map.Entry<K,V>> entrySet;

transient int size;

transient int modCount;

int threshold;

final float loadFactor;

共有6个静态变量,都设置了初始值,且被final修饰,叫常量更合适,它们的作用其实也能猜出来,就是用于成员变量的默认值设定以及方法中相关的条件判断等情况。

共有6个成员变量,除这些成员变量外,还有一个重要概念capacity,我们主要说一下table,entrySet,capacity, size,threshold,loadFactor,我们我们简单解释一下它们的作用。

1. table变量

table变量为HashMap的底层数据结构,用于存储添加到HashMap中的Key-value对,是一个Node数组,Node是一个静态内部类,一种数组和链表相结合的复合结构,我们看一下Node类:

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 + "=" + value; }

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

    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;
    }
}

若以乘机做比喻的话,那么你买票的身份证号就是(key),通过hash算法生成的(hash)值就相当于值机后得到的航班座位号;你自己自然就是(value),你旁边的座位、人就是下一个Node(next);这样的一个座位整体(包括所坐人员及其身份证号、座位号)就是一个table,这许多的table的构建的Node[] table,就构成了本次航班任务。

那么为什么要用到数组和链表结合的数据结构?

我们知道数组和链表都有其各自的优点和缺点,数组连续存储,寻址容易,插入删除操作相对困难;而链表离散存储,寻址相对困难,而插入删除操作容易;而HashMap结合了这两种数据结构,保留了各自的优点,又弥补了各自的缺点,当然链表长度太长的话,在JDK8中会转化为红黑树,红黑树在后面的TreeMap章节在讲解。

HashMap的结构图如下:

怎么解释这种结构呢?

还是以乘机为例来说明,假如购票系统比较人性化并取消了值机操作,购票按照年龄段进行了区分,方便大家旅途沟通交流,于是20岁以下共6个人的分为了一组在20A20F,2030岁共6个人分为一组在21A21F,3040岁共6个人分为一组在22A22F,4050岁共6个人分为一组在23A~23F。

这时我们如果要找20几岁的小姐姐,我们很容易知道去21排找,从21A开始往下找,应该就能很快找到。

从数据的角度看,按年龄段分组(通过hash算法得到hash值,不同年龄段hash值不同,相同年龄段hash值相同)后,将各年龄段中第一个坐到座位上的人放到数组table中,下一个人来的时候,将第一个人往里面挪,自己在数组里,并将next指向第一个人。

2. entrySet变量

entrySet变量为EntrySet实体,定义为变量可保证不重复多次创建,是一个Map.Entry的集合,Map.Entry<K,V>是一个接口,Node类就实现了该接口,因此EntrySet中方法需要操作的数据就是HashMap的Node实体。

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

3. capacity

capacity并不是一个成员变量,但HashMap中很多地方都会使用到这个概念,意思是容量,很好理解,在前面的文中提到了两个常量都与之相关

/**
 * The default initial capacity - MUST be a power of two(必须为2的幂次).
 * 默认容量16,举例:飞机上正常的座位所对应的人员数量,
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
 * The maximum capacity, used if a higher value is implicitly specified
 * by either of the constructors with arguments.
 * MUST be a power of two <= 1<<30(必须为2的幂次,且不能大于最大容量1,073,741,824).
 * 举例:紧急情况下,如救灾时尽可能快撤离人员,这个时候在保证安全的情况下(允许站立),能运输的人员数
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

同时HashMap还具有扩容机制,容量的规则为2的幂次,即capacity可以是1,2,4,8,16,32...,怎么实现这种容量规则呢?

/**
 * Returns a power of two size for the given target capacity.
 */
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;
}

用该方法即可找到传递进来的容量的最近的2的幂次,即

cap = 2, return 2;

cap = 3, return 4;

cap = 9, return 16;

...

大家可以传递值进去自己算一下,先cap-1操作,是因为当传递的cap本身就是2的幂次情况下,假如为4,不减去一最后得到的结果将是传递的cap的2倍。

我们来一行行计算一下:tableSizeFor(11),按规则最后得到的结果应该是16

//第一步:n = 10,转为二进制为00001010
int n = cap - 1;
//第二步:n右移1位,高位补0(10进制:5,二进制:00000101),并与n做或运算(有1为1,同0为0),然后赋值给n(10进制:15,二进制:00001111)
n |= n >>> 1;
//第三步:n右移2位,高位补0(10进制:3,二进制:00000011),并与n做或运算(有1为1,同0为0),然后赋值给n(10进制:15,二进制:00001111)
n |= n >>> 2;
//第四步:n右移4位,高位补0(10进制:0,二进制:00000000),并与n做或运算(有1为1,同0为0),然后赋值给n(10进制:15,二进制:00001111)
n |= n >>> 4;
//第五步:n右移8位,高位补0(10进制:0,二进制:00000000),并与n做或运算(有1为1,同0为0),然后赋值给n(10进制:15,二进制:00001111)
n |= n >>> 8;
//第六步:n右移16位,高位补0(10进制:0,二进制:00000000),并与n做或运算(有1为1,同0为0),然后赋值给n(10进制:15,二进制:00001111)
n |= n >>> 16;
//第七步:return 15+1 = 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

最终的结果正如预期,算法很牛逼啊,ヽ(ー_ー)ノ,能看懂,但却设计不出来。

4. size变量

size变量记录了Map中的key-value对的数量,在调用putValue()方法以及removeNode()方法时,都会对其造成改变,和capacity区分一下即可。

5. threshold变量和loadFactor变量

threshold为临界值,顾名思义,当过了临界值就需要做一些操作了,在HashMap中临界值“threshold = capacity * loadFactor”,当超过临界值时,HashMap就该扩容了。

loadFactor为装载因子,就是用来衡量HashMap满的程度,默认值为DEFAULT_LOAD_FACTOR,即0.75f,可通过构造器传递参数调整(0.75f已经很合理了,基本没人会去调整它),很好理解,举个例子:

100分的试题,父母只需要你考75分,就给你买一台你喜欢的电脑,装载因子就是0.75,75分就是临界值;如果几年后,试题的分数变成200分了,这个时候就需要你考到150分才能得到你喜欢的电脑了。

总结

本文主要讲解了HashMap中的一些主要概念,同时对其底层数据结构从源码的角度进行了分析,table是一个数据和链表的复合结构,size记录了key-value对的数量,capacity为HashMap的容量,其容量规则为2的幂次,loadFactor为装载因此,衡量满的程度,而threshold为临界值,当超出临界值时就会扩容。

HashMap原理(二) 扩容机制及存取原理

 

我们在上一个章节《HashMap原理(一) 概念和底层架构》中讲解了HashMap的存储数据结构以及常用的概念及变量,包括capacity容量,threshold变量和loadFactor变量等。本章主要讲解HashMap的扩容机制及存取原理。

先回顾一下基本概念:

table变量:HashMap的底层数据结构,是Node类的实体数组,用于保存key-value对;

capacity:并不是一个成员变量,但却是一个必须要知道的概念,表示容量;

size变量:表示已存储的HashMap的key-value对的数量;

loadFactor变量:装载因子,用于衡量满的程度;

threshold变量:临界值,当超出该值时,表示table表示该扩容了;

一. put方法

HashMap使用哈希算法得到数组中保存的位置,然后调用put方法将key-value对保存到table变量中。我们通过图来演示一下存储的过程。

简单解释一下:

1)通过hash(Object key)算法得到hash值;

2)判断table是否为null或者长度为0,如果是执行resize()进行扩容;

3)通过hash值以及table数组长度得到插入的数组索引i,判断数组table[i]是否为空或为null;
4)如果table[i] == null,直接新建节点添加,转向 8),如果table[i]不为空,转向 5);
5)判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,这里的相同指的是hashCode以及equals,否则转向 6);
6)判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转7);
7)遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
8)插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

我们关注一下这里面最重要的三个方法,hash(),putVal(),resize().

1. hash方法

我们通过hash方法计算索引,得到数组中保存的位置,看一下源码

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

我们可以看到HashMap中的hash算法是通过key的hashcode值与其hashcode右移16位后得到的值进行异或运算得到的,那么为什么不直接使用key.hashCode(),而要进行异或操作?我们知道hash的目的是为了得到进行索引,而hash是有可能冲突的,也就是不同的key得到了同样的hash值,这样就很容易产业碰撞,如何减少这种情况的发生呢,就通过上述的hash(Object key)算法将hashcode 与 hashcode的低16位做异或运算,混合了高位和低位得出的最终hash值,冲突的概率就小多了。举个例子:

有个蒸笼,第一层是猪肉包、牛肉包、鸡肉包,第二层是白菜包,第三层是豆沙包,第四层是香菇包。这时你来买早餐,你指着第一层说除了猪肉包,随便给我一个包子,因为外表无法分辨,这时拿到猪肉包的概率就有1/3,如果将二层、三层、四层与一层混合在一起了,那么拿到猪肉包的概率就小多了。

我们的hash(Object key)算法一个道理,最终的hash值混合了高位和低位的信息,掺杂的元素多了,那么最终hash值的随机性越大,而HashMap的table下标依赖于最终hash值与table.length()-1的&运算,这里的&运算类似于挑包子的过程,自然冲突就小得多了。计算过程如下:

最开始的hashCode: 1111 1111 1111 1111 0100 1100 0000 1010

右移16位的hashCode:0000 0000 0000 0000 1111 1111 1111 1111

异或运算后的hash值: 1111 1111 1111 1111 1011 0011 1111 0101

2. putVal方法

通过putVal方法将传递的key-value对添加到数组table中。

/**
 * Implements Map.put and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    /**
     * 如果当前HashMap的table数组还未定义或者还未初始化其长度,则先通过resize()进行扩容,
     * 返回扩容后的数组长度n
     */
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //通过数组长度与hash值做按位与&运算得到对应数组下标,若该位置没有元素,则new Node直接将新元素插入
    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;
        /**
         * 否则key不同的情况下,判断当前Node是否是TreeNode,如果是则执行putTreeVal将新的元素插入
         * 到红黑树上。
         */
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //如果不是TreeNode,则进行链表遍历
        else {
            for (int binCount = 0; ; ++binCount) {
                /**
                 * 在链表最后一个节点之后并没有找到相同的元素,则进行下面的操作,直接new Node插入,
                 * 但条件判断有可能转化为红黑树
                 */
                if ((e = p.next) == null) {
                    //直接new了一个Node
                    p.next = newNode(hash, key, value, null);
                    /**
                     * TREEIFY_THRESHOLD=8,因为binCount从0开始,也即是链表长度超过8(包含)时,
                     * 转为红黑树。
                     */
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                /**
                 * 如果在链表的最后一个节点之前找到key值相同的(和上面的判断不冲突,上面是直接通过数组
                 * 下标判断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;
            //onlyIfAbsent为true时:当某个位置已经存在元素时不去覆盖
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //最后判断临界值,是否扩容。
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

3. resize方法

HashMap通过resize()方法进行扩容,容量规则为2的幂次

/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 *
 * @return the table
 */
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //以前的容量大于0,也就是hashMap中已经有元素了,或者new对象的时候设置了初始容量
    if (oldCap > 0) {
        //如果以前的容量大于限制的最大容量1<<30,则设置临界值为int的最大值2^31-1
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        /**
         * 如果以前容量的2倍小于限制的最大容量,同时大于或等于默认的容量16,则设置临界值为以前临界值的2
         * 倍,因为threshold = loadFactor*capacity,capacity扩大了2倍,loadFactor不变,
         * threshold自然也扩大2倍。
         */
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    /**
     * 在HashMap构造器Hash(int initialCapacity, float loadFactor)中有一句代码,this.threshold  	
     * = tableSizeFor(initialCapacity), 表示在调用构造器时,默认是将初始容量暂时赋值给了
     * threshold临界值,因此此处相当于将上一次的初始容量赋值给了新的容量。什么情况下会执行到这句?当调用 	 
     * 了HashMap(int initialCapacity)构造器,还没有添加元素时
     */
    else if (oldThr > 0) 
        newCap = oldThr;
    /**
     * 调用了默认构造器,初始容量没有设置,因此使用默认容量DEFAULT_INITIAL_CAPACITY(16),临界值
     * 就是16*0.75
     */
    else {               
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //对临界值做判断,确保其不为0,因为在上面第二种情况(oldThr > 0),并没有计算newThr
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    @SuppressWarnings({"rawtypes","unchecked"})
    /**构造新表,初始化表中数据*/
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    //将刚创建的新表赋值给table
    table = newTab;
    if (oldTab != null) {
        //遍历将原来table中的数据放到扩容后的新表中来
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                //没有链表Node节点,直接放到新的table中下标为【e.hash & (newCap - 1)】位置即可
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //如果是treeNode节点,则树上的节点放到newTab中
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                //如果e后面还有链表节点,则遍历e所在的链表,
                else { // 保证顺序
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        //记录下一个节点
                        next = e.next;
                        /**
                         * newTab的容量是以前旧表容量的两倍,因为数组table下标并不是根据循环逐步递增
                         * 的,而是通过(table.length-1)& hash计算得到,因此扩容后,存放的位置就
                         * 可能发生变化,那么到底发生怎样的变化呢,就是由下面的算法得到.
                         *
                         * 通过e.hash & oldCap来判断节点位置通过再次hash算法后,是否会发生改变,如
                         * 果为0表示不会发生改变,如果为1表示会发生改变。到底怎么理解呢,举个例子:
                         * e.hash = 13 二进制:0000 1101
                         * oldCap = 32 二进制:0001 0000
                         *  &运算:  0  二进制:0000 0000
                         * 结论:元素位置在扩容后不会发生改变
                         */
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        /**
                         * e.hash = 18 二进制:0001 0010
                         * oldCap = 32 二进制:0001 0000
                         * &运算:  32 二进制:0001 0000
                         * 结论:元素位置在扩容后会发生改变,那么如何改变呢?
                         * newCap = 64 二进制:0010 0000
                         * 通过(newCap-1)&hash
                         * 即0001 1111 & 0001 0010 得0001 0010,32+2 = 34
                         */
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        /**
                         * 若(e.hash & oldCap) == 0,下标不变,将原表某个下标的元素放到扩容表同样
                         * 下标的位置上
                         */
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        /**
                         * 若(e.hash & oldCap) != 0,将原表某个下标的元素放到扩容表中
                         * [下标+增加的扩容量]的位置上
                         */
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

二. get方法

我们先简单说一下get(Object key)流程,通过传入的key通过hash()算法得到hash值,在通过(n - 1) & hash找到数组下标,如果数组下标所对应的node值正好key一样就返回,否则找到node.next找到下一个节点,看是否是treenNode,如果是,遍历红黑树找到对应node,如果不是遍历链表找到node。我们看一下源码

public V get(Object key) {
    Node<K,V> e;
    //先通过hash(key)找到hash值,然后调用getNode(hash,key)找到节点
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
 * Implements Map.get and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @return the node, or null if none
 */
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //通过(n - 1) & hash找到数组对应位置上的第一个node
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //如果这个node刚好key值相同,直接返回
        if (first.hash == hash && 
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //如果不相同就再往下找
        if ((e = first.next) != null) {
            //如果是treeNode,就遍历红黑树找到对应node
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //如果是链表,遍历链表找到对应node
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

这几个方法是核心,虽然HashMap还有很多常用方法,不过大体和这几个方法有关,或者实现逻辑相似,这里就不再多说了。

三. 总结

本文在上一章基本概念和底层结构的基础上,从源码的角度讲解了扩容机制以及存取原理,主要分析了put方法和get方法,put方法的核心为hash(),putVal(),resize(),get方法的核心为getNode(),若有不对之处,请批评指正,望共同进步,谢谢!

LinkedHashMap如何保证顺序性

 

一. 前言

先看一个例子,我们想在页面展示一周内的消费变化情况,用echarts面积图进行展示。如下:

我们在后台将数据构造完成

HashMap<String, Integer> map = new HashMap<>();
map.put("星期一", 40);
map.put("星期二", 43);
map.put("星期三", 35);
map.put("星期四", 55);
map.put("星期五", 45);
map.put("星期六", 35);
map.put("星期日", 30);

然而页面上一展示,发现并非如此,我们打印出来看,发现顺序并非我们所想,先put进去的先get出来

for (Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println("key: " + entry.getKey() + ", value: " + entry.getValue());
}
/**
 * 结果如下:
 * key: 星期二, value: 40
 * key: 星期六, value: 35
 * key: 星期三, value: 50
 * key: 星期四, value: 55
 * key: 星期五, value: 45
 * key: 星期日, value: 65
 * key: 星期一, value: 30
 */

那么如何保证预期展示结果如我们所想呢,这个时候就需要用到LinkedHashMap实体。

二. 初识LinkedHashMap

首先我们把上述代码用LinkedHashMap进行重构

LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("星期一", 40);
map.put("星期二", 43);
map.put("星期三", 35);
map.put("星期四", 55);
map.put("星期五", 45);
map.put("星期六", 35);
map.put("星期日", 30);
for (Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println("key: " + entry.getKey() + ", value: " + entry.getValue());
}

这个时候,结果正如我们所预期

key: 星期一, value: 40
key: 星期二, value: 43
key: 星期三, value: 35
key: 星期四, value: 55
key: 星期五, value: 45
key: 星期六, value: 35
key: 星期日, value: 30

LinkedHashMap继承了HashMap类,是HashMap的子类,LinkedHashMap的大多数方法的实现直接使用了父类HashMap的方法,关于HashMap在前面的章节已经讲过了,《HashMap原理(一) 概念和底层架构》《HashMap原理(二) 扩容机制及存取原理》

LinkedHashMap可以说是HashMap和LinkedList的集合体,既使用了HashMap的数据结构,又借用了LinkedList双向链表的结构(关于LinkedList可参考Java集合 LinkedList的原理及使用),那么这样的结构如何实现的呢,我们看一下LinkedHashMap的类结构

我们看到LinkedHashMap中定义了一个Entry静态内部类,定义了5个构造器,一些成员变量,如head,tail,accessOrder,并继承了HashMap的方法,同时实现了一些迭代器方法。我们先看一下Entry类

static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

我们看到这个静态内部类很简单,继承了HashMap的Node内部类,我们知道Node类是HashMap的底层数据结构,实现了数组+链表/红黑树的结构,而Entry类保留了HashMap的数据结构,同时通过before,after实现了双向链表结构(HashMap中Node类只有next属性,并不具备双向链表结构)。那么before,after和next到底什么关系呢。

看上面的结构图,定义了头结点head,当我们调用迭代器进行遍历时,通过head开始遍历,通过before属性可以不断找到下一个,直到tail尾结点,从而实现顺序性。而在同一个hash(在上图中表现了同一行)链表内部after和next效果是一样的。不同点在于before和after可以连接不同hash之间的链表。

前面我们发现数据结构已经完全支持其顺序性了,接下来我们再看一下构造方法,看一下比起HashMap的构造方法是否有不同。

// 构造方法1,构造一个指定初始容量和负载因子的、按照插入顺序的LinkedList
public LinkedHashMap(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor);
    accessOrder = false;
}
// 构造方法2,构造一个指定初始容量的LinkedHashMap,取得键值对的顺序是插入顺序
public LinkedHashMap(int initialCapacity) {
    super(initialCapacity);
    accessOrder = false;
}
// 构造方法3,用默认的初始化容量和负载因子创建一个LinkedHashMap,取得键值对的顺序是插入顺序
public LinkedHashMap() {
    super();
    accessOrder = false;
}
// 构造方法4,通过传入的map创建一个LinkedHashMap,容量为默认容量(16)和(map.zise()/DEFAULT_LOAD_FACTORY)+1的较大者,装载因子为默认值
public LinkedHashMap(Map<? extends K, ? extends V> m) {
    super(m);
    accessOrder = false;
}
// 构造方法5,根据指定容量、装载因子和键值对保持顺序创建一个LinkedHashMap
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}

我们发现除了多了一个变量accessOrder之外,并无不同,此变量到底起了什么作用?

/**
 * The iteration ordering method for this linked hash map: <tt>true</tt>
 * for access-order, <tt>false</tt> for insertion-order.
 *
 * @serial
 */
final boolean accessOrder;

通过注释发现该变量为true时access-order,即按访问顺序遍历,如果为false,则表示按插入顺序遍历。默认为false,在哪些地方使用到该变量了,同时怎么理解?我们可以看下面的方法介绍

二. put方法

前面我们提到LinkedHashMap的put方法沿用了父类HashMap的put方法,但我们也提到了像LinkedHashMap的Entry类就是继承了HashMap的Node类,同样的,HashMap的put方法中调用的其他方法在LinkedHashMap中已经被重写。我们先看一下HashMap的put方法,这个在《HashMap原理(二) 扩容机制及存取原理》中已经有说明,我们主要关注于其中的不同点

/**
 * Implements Map.put and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    /**
     * 如果当前HashMap的table数组还未定义或者还未初始化其长度,则先通过resize()进行扩容,
     * 返回扩容后的数组长度n
     */
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //通过数组长度与hash值做按位与&运算得到对应数组下标,若该位置没有元素,则new Node直接将新元素插入
    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;
        /**
         * 否则key不同的情况下,判断当前Node是否是TreeNode,如果是则执行putTreeVal将新的元素插入
         * 到红黑树上。
         */
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //如果不是TreeNode,则进行链表遍历
        else {
            for (int binCount = 0; ; ++binCount) {
                /**
                 * 在链表最后一个节点之后并没有找到相同的元素,则进行下面的操作,直接new Node插入,
                 * 但条件判断有可能转化为红黑树
                 */
                if ((e = p.next) == null) {
                    //直接new了一个Node
                    p.next = newNode(hash, key, value, null);
                    /**
                     * TREEIFY_THRESHOLD=8,因为binCount从0开始,也即是链表长度超过8(包含)时,
                     * 转为红黑树。
                     */
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                /**
                 * 如果在链表的最后一个节点之前找到key值相同的(和上面的判断不冲突,上面是直接通过数组
                 * 下标判断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;
            //onlyIfAbsent为true时:当某个位置已经存在元素时不去覆盖
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //最后判断临界值,是否扩容。
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

1. newNode方法

首先:LinkedHashMap重写了newNode()方法,通过此方法保证了插入的顺序性。

/**
 * 使用LinkedHashMap中内部类Entry
 */
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p = new LinkedHashMap.Entry<K,V>(hash, key, value, e);
    linkNodeLast(p);
    return p;
}
/**
 * 将新创建的节点p作为尾结点tail,
 * 当然如果存储的第一个节点,那么它即是head节点,也是tail节点,此时节点p的before和after都为null
 * 否则,建立与上一次尾结点的链表关系,将当前尾节点p的前一个节点(before)设置为上一次的尾结点last,
 * 将上一次尾节点last的后一个节点(after)设置为当前尾结点p
 * 通过此方法实现了双向链表功能,完成before,after,head,tail的值设置
 */
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    if (last == null)
        head = p;
    else {
        p.before = last;
        last.after = p;
    }
}

2. afterNodeAccess方法

其次:关于afterNodeAccess()方法,在HashMap中没给具体实现,而在LinkedHashMap重写了,目的是保证操作过的Node节点永远在最后,从而保证读取的顺序性,在调用put方法和get方法时都会用到。

/**
 * 当accessOrder为true并且传入的节点不是最后一个时,将传入的node移动到最后一个
 */
void afterNodeAccess(Node<K,V> e) {
    //在执行方法前的上一次的尾结点
    LinkedHashMap.Entry<K,V> last;
    //当accessOrder为true并且传入的节点并不是上一次的尾结点时,执行下面的方法
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        //p:当前节点
        //b:当前节点的前一个节点
        //a:当前节点的后一个节点;
        
        //将p.after设置为null,断开了与后一个节点的关系,但还未确定其位置
        p.after = null;
        /**
         * 因为将当前节点p拿掉了,那么节点b和节点a之间断开了,我们先站在节点b的角度建立与节点a
         * 的关联,如果节点b为null,表示当前节点p是头结点,节点p拿掉后,p的下一个节点a就是头节点了;
         * 否则将节点b的后一个节点设置为节点a
         */
        if (b == null)
            head = a;
        else
            b.after = a;
        /**
         * 因为将当前节点p拿掉了,那么节点a和节点b之间断开了,我们站在节点a的角度建立与节点b
         * 的关联,如果节点a为null,表示当前节点p为尾结点,节点p拿掉后,p的前一个节点b为尾结点,
         * 但是此时我们并没有直接将节点p赋值给tail,而是给了一个局部变量last(即当前的最后一个节点),因为
         * 直接赋值给tail与该方法最终的目标并不一致;如果节点a不为null将节点a的前一个节点设置为节点b
         *
         * (因为前面已经判断了(last = tail) != e,说明传入的节点并不是尾结点,既然不是尾结点,那么
         * e.after必然不为null,那为什么这里又判断了a == null的情况?
         * 以我的理解,java可通过反射机制破坏封装,因此如果都是反射创建出的Entry实体,可能不会满足前面
         * 的判断条件)
         */
        if (a != null)
            a.before = b;
        else
            last = b;
        /**
         * 正常情况下last应该也不为空,为什么要判断,原因和前面一样
         * 前面设置了p.after为null,此处再将其before值设置为上一次的尾结点last,同时将上一次的尾结点
         * last设置为本次p
         */
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        //最后节点p设置为尾结点,完事
        tail = p;
        ++modCount;
    }
}

我们前面说到的linkNodeLast(Entry e)方法和现在的afterNodeAccess(Node e)都是将传入的Node节点放到最后,那么它们的使用场景如何呢?

在前面讲解HashMap时,提到了HashMap的put流程,如果在对应的hash位置上还没有元素,那么直接new Node()放到数组table中,这个时候对应到LinkedHashMap中,调用了newNode()方法,就会用到linkNodeLast(),将新node放到最后,而如果对应的hash位置上有元素,进行元素值的覆盖时,就会调用afterNodeAccess(),将原本可能不是最后的node节点拿到了最后。如

LinkedHashMap<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("1月", 20);
//此时就会调用到linkNodeLast()方法,也会调用afterNodeAccess()方法,但会被阻挡在
//if (accessOrder && (last = tail) != e) 之外
map.put("2月", 30);
map.put("3月", 65);
map.put("4月", 43);
//这时不会调用linkNodeLast(),会调用afterNodeAccess()方法将key为“1月”的元素放到最后
map.put("1月", 35);
//这时不会调用linkNodeLast(),会调用afterNodeAccess()方法将key为“2月”的元素放到最后
map.get("2月");
//调用打印方法
for (Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println("key: " + entry.getKey() + ", value: " + entry.getValue());
}

结果如下:

key: 3月, value: 65
key: 4月, value: 43
key: 1月, value: 35
key: 2月, value: 30

而如果是执行下面这段代码,将accessOrder改为false

LinkedHashMap<String, Integer> map = new LinkedHashMap<>(16, 0.75f, false);
map.put("1月", 20);
//此时就会调用到linkNodeLast()方法,也会调用afterNodeAccess()方法,但会被阻挡在
//if (accessOrder && (last = tail) != e) 之外
map.put("2月", 30);
map.put("3月", 65);
map.put("4月", 43);
//这时不会调用linkNodeLast(),会调用afterNodeAccess()方法将key为“1月”的元素放到最后
map.put("1月", 35);
map.get("2月");
//调用打印方法
for (Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println("key: " + entry.getKey() + ", value: " + entry.getValue());
}

结果如下:

key: 1月, value: 35
key: 2月, value: 30
key: 3月, value: 65
key: 4月, value: 43

大家看到区别了吗,accessOrder为false时,你访问的顺序就是按照你第一次插入的顺序;而accessOrder为true时,你任何一次的操作,包括put、get操作,都会改变map中已有的存储顺序。

3. afterNodeInsertion方法

我们看到在LinkedHashMap中还重写了afterNodeInsertion(boolean evict)方法,它的目的是移除链表中最老的节点对象,也就是当前在头部的节点对象,但实际上在JDK8中不会执行,因为removeEldestEntry方法始终返回false。看源码:

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}

三. get方法

LinkedHashMap的get方法与HashMap中get方法的不同点也在于多了afterNodeAccess()方法

public V get(Object key) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return null;
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

在这里就不再多讲了,getNode()方法在HashMap章节已经讲过,而前面刚把afterNodeAccess讲了。

四.remove方法

remove方法也直接使用了HashMap中的remove,在HashMap章节并没有讲解,因为remove的原理很简单,通过传递的参数key计算出hash,据此可找到对应的Node节点,接下来如果该Node节点是直接在数组中的Node,则将table数组该位置的元素设置为node.next;如果是链表中的,则遍历链表,直到找到对应的node节点,然后建立该节点的上一个节点的next设置为该节点的next。

LinkedHashMap重写了其中的afterNodeRemoval(Node e),该方法在HashMap中没有具体实现,通过此方法在删除节点的时候调整了双链表的结构。

void afterNodeRemoval(Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    //将待删除节点的before和after都设置为null
    p.before = p.after = null;
    /**
     * 如果节点b为null,表示待删除节点p为头部节点,该节点拿掉后,该节点的下一个节点a就为头部节点head
     * 否则设置待删除节点的上一个节点b的after属性为节点a 
     */
    if (b == null)
        head = a;
    else
        b.after = a;
    /**
     * 如果节点a为null,表示待删除节点p为尾部节点,该节点拿掉后,该节点的上一个节点a就为尾部节点tail
     * 否则设置待删除节点的下一个节点a的before属性为节点b 
     */
    if (a == null)
        tail = b;
    else
        a.before = b;
}

五. 总结

LinkedHashMap使用的也较为频繁,它基于HashMap,用于HashMap的特点,又增加了双链表的结构,从而保证了顺序性,本文主要从源码的角度分析其如何保证顺序性,accessOrder的解释,以及常用方法的阐释,若有不对之处,请批评指正,望共同进步,谢谢!

关于红黑树(R-B tree)原理,看这篇如何

 

学过数据数据结构都知道二叉树的概念,而又有多种比较常见的二叉树类型,比如完全二叉树、满二叉树、二叉搜索树、均衡二叉树、完美二叉树等;今天我们要说的红黑树就是就是一颗非严格均衡的二叉树,均衡二叉树又是在二叉搜索树的基础上增加了自动维持平衡的性质,插入、搜索、删除的效率都比较高。红黑树也是实现TreeMap存储结构的基石。

一. 二叉搜索树

二叉搜索树又叫二叉查找树、二叉排序树,我们先看一下典型的二叉搜索树,这样的二叉树有何规则特点呢?

  1. 节点的左子树小于节点本身;
  2. 节点的右子树大于节点本身;
  3. 左右子树同样为二叉搜索树;

下图就是一颗典型的二叉搜索树

二叉搜索树是均衡二叉树的基础,我们看一下它的搜索步骤如何

我们要从二叉树中找到值为 58 的节点

第一步:首先查找到根节点,值为60的节点

第二步:比较我们要找的值58与该节点的大小

如果等于,那么恭喜,已经找到;

如果小于,继续找左子树;

如果大于,那么找右子树;

很明显58 < 60,因此我们找到左子树的节点 56,此时我们已经定位到了节点56

第三步:按照第二步的规则继续找

58 > 56 我们需要继续找右子树,定位到了右子树节点58,恭喜,此时我们已经找到了。

我们经过三步就已经找到了,其实就是我们平时所说的二分查找,这种二叉搜索树好像查找效率很高,但同样它也有缺陷,如下面这样的二叉搜索树。

看到这样的二叉搜索树是否很别扭,典型的大长腿瘸子,但它也是二叉搜索树,如果我们要找值为50的节点,基本上和单链表查询没多大区别了,性能将大打折扣。这个时候我们的均衡二叉树就粉墨登场了,均衡二叉树就是在二叉搜索树的基础上添加了自动维持平衡的性质。

上面的大长腿瘸子二叉搜索树经过自动平衡后,可能就成为了下面这样的二叉树。

经过了自动平衡,再去找值为50的节点,查找性能将提升很多。红黑树就是非严格均衡的二叉搜索树。

二. 红黑树规则特点

红黑树具体有哪些规则特点呢?

  1. 节点分为红色或者黑色;
  2. 根节点必为黑色;
  3. 叶子节点都为黑色,且为null;
  4. 连接红色节点的两个子节点都为黑色(红黑树不会出现相邻的红色节点);
  5. 从任意节点出发,到其每个叶子节点的路径中包含相同数量的黑色节点;
  6. 新加入到红黑树的节点为红色节点;

规则看着好像挺多,没错,因为红黑树也是均衡二叉树,需要具备自动维持平衡的性质,上面的6条就是红黑树给出的自动维持平衡所需要具备的规则

我们看一看一个典型的红黑树到底是什么样儿?

首先解读一下规则,除了字面上看到的意思,还隐藏了哪些意思呢?

第一. 从根节点到叶子节点的最长路径不大于最短路径的2倍

  怎么样的路径算最短路径?

  从规则5中,我们知道从根节点到每个叶子节点的黑色节点数量是一样的,那么纯由黑色节点组成的路径就是最短路径;

  什么样的路径算是最长路径?

  根据规则4和规则3,若有红色节点,则必然有一个连接的黑色节点,当红色节点和黑色节点数量相同时,就是最长路径,也就是黑色节点(或红色节点)* 2

第二. 为什么说新加入到红黑树中的节点为红色节点

  从规则4中知道,当前红黑树中从根节点到每个叶子节点的黑色节点数量是一样的,此时假如新的黑色节点的话,必然破坏规则,但加入红色节点却不一定,除非其父节点就是红色节点,因此加入红色节点,破坏规则的可能性小一些,下面我们也会举例来说明。

什么情况下,红黑树的结构会被破坏呢?破坏后又怎么维持平衡,维持平衡主要通过两种方式【变色】和【旋转】,【旋转】又分【左旋】和【右旋】,两种方式可相互结合。

下面我们从插入和删除两种场景来举例说明

三. 红黑树节点插入

当我们插入值为66的节点时,红黑树变成了这样

很明显,这个时候结构依然遵循着上述6大规则,无需启动自动平衡机制调整节点平衡状态;

如果再向里面插入值为51的节点呢,这个时候红黑树变成了这样

很明显现在的结构不遵循规则 4 了,这个时候就需要启动自动平衡机制调整节点平衡状态

3.1 变色

我们可以通过变色的方式,使结构满足红黑树的规则

  • 首先解决结构不遵循规则 4 这一点(红色节点相连,节点49-51),需将节点49改为黑色;
  • 此时我们发现又违反了规则5(56-49-51-XX路径中黑色节点超过了其他路径),那么我们将节点45改为红色节点;
  • 哈哈,妹的,又违反了规则4(红色节点相连,节点56-45-43),那么我们将节点56和节点43改为黑色节点;
  • 但是我们发现此时又违反了规则5(60-56-XX路径的黑色节点比60-68-XX的黑色节点多),因此我们需要调整节点68为黑色;
  • 完成!!

最终调整完成后的树为:

但并不是什么时候都那么幸运,可以直接通过变色就达成目的,大多数时候还需要通过旋转来解决。

如在下面这棵树的基础上,加入节点65.

插入节点65后进行以下步骤

这个时候,你会发现对于节点64无论是红色节点还是黑色节点,都会违反规则5,路径中的黑色节点始终无法达成一致,这个时候仅通过【变色】已经无法达成目的。我们需要通过旋转操作,当然【旋转】操作一般还需要搭配【变色】操作。

旋转包括【左旋】和【右旋】,

左旋:

逆时针旋转两个节点,让一个节点被其右子节点取代,而该节点成为右子节点的左子节点

左旋操作步骤如下:

首先断开节点PL与右子节点G的关系,同时将其右子节点的引用指向节点C2;然后断开节点G与左子节点C2的关系,同时将G的左子节点的应用指向节点PL

右旋:

顺时针旋转两个节点,让一个节点被其左子节点取代,而该节点成为左子节点的右子节点

右旋操作步骤如下:

首先断开节点G与左子节点PL的关系,同时将其左子节点的引用指向节点C2;然后断开节点PL与右子节点C2的关系,同时将PL的右子节点的应用指向节点G

无法通过变色而进行旋转的场景分为以下四种:

3.2 左左节点旋转

这种情况下,父节点和插入的节点都是左节点,如下图(旋转原始图1)这种情况下,我们要插入节点65

规则如下:以祖父节点【右旋】,搭配【变色】

按照规则,步骤如下:

3.3 左右节点旋转

这种情况下,父节点是左节点,插入的节点是右节点,在旋转原始图1中,我们要插入节点67

规则如下:先父节点【左旋】,然后祖父节点【右旋】,搭配【变色】

按照规则,步骤如下:

3.4 右左节点旋转

这种情况下,父节点是右节点,插入的节点是左节点,如下图(旋转原始图2)这种情况,我们要插入节点68

规则如下:先父节点【右旋】,然后祖父节点【左旋】,搭配【变色】

按照规则,步骤如下:

3.5 右右节点旋转

这种情况下,父节点和插入的节点都是右节点,在旋转原始图2中,我们要插入节点70

规则如下:以祖父节点【左旋】,搭配【变色】

按照规则,步骤如下:

3.6 红黑树插入总结

 无需调整【变色】即可实现平衡【旋转+变色】才可实现平衡
情况1: 当父节点为黑色时插入子节点 空树插入根节点,将根节点红色变为黑色 父节点为红色左节点,叔父节点为黑色,插入左子节点,那么通过【左左节点旋转】
情况2: - 父节点和叔父节点都为红色 父节点为红色左节点,叔父节点为黑色,插入右子节点,那么通过【左右节点旋转】
情况3: - - 父节点为红色右节点,叔父节点为黑色,插入左子节点,那么通过【右左节点旋转】
情况4: - - 父节点为红色右节点,叔父节点为黑色,插入右子节点,那么通过【右右节点旋转】

四. 红黑树节点删除

相比较于红黑树的节点插入,删除节点更为复杂,我们从子节点是否为null和红色为思考维度来讨论。

4.1 子节点至少有一个为null

当待删除的节点的子节点至少有一个为null节点时,删除了该节点后,将其有值的节点取代当前节点即可,若都为null,则将当前节点设置为null,当然如果违反规则了,则按需调整,如【变色】以及【旋转】。

4.2 子节点都是非null节点

这种情况下,

第一步:找到该节点的前驱或者后继

前驱:左子树中值最大的节点(可得出其最多只有一个非null子节点,可能都为null);

后继:右子树中值最小的节点(可得出其最多只有一个非null子节点,可能都为null);

前驱和后继都是值最接近该节点值的节点,类似于该节点.prev = 前驱,该节点.next = 后继。

第二步:将前驱或者后继的值复制到该节点中,然后删掉前驱或者后继

如果删除的是左节点,则将前驱的值复制到该节点中,然后删除前驱;如果删除的是右节点,则将后继的值复制到该节点中,然后删除后继;

这相当于是一种“取巧”的方法,我们删除节点的目的是使该节点的值在红黑树上不存在,因此专注于该目的,我们并不关注删除节点时是否真是我们想删除的那个节点,同时我们也不需考虑树结构的变化,因为树的结构本身就会因为自动平衡机制而经常进行调整。

前面我们已经说了,我们要删除的实际上是前驱或者后继,因此我们就以前驱为主线来讲解,后继的学习可参考前驱,包括几种情况

4.2.1 前驱为黑色节点,并且有一个非null子节点

分析:

因为要删除的是左节点64,找到该节点的前驱63;

然后用前驱的值63替换待删除节点的值64,此时两个节点(待删除节点和前驱)的值都为63;

删除前驱63,此时成为上图过程中间环节,但我们发现其不符合红黑树规则4,因此需要进行自动平衡调整;

这里直接通过【变色】即可完成。

4.2.2 前驱为黑色节点,同时子节点都为null

分析:

因为要删除的是左节点64,找到该节点的前驱63;

然后用前驱的值63替换待删除节点的值64,此时两个节点(待删除节点和前驱)的值都为63;

删除前驱63,此时成为上图过程中间环节,但我们发现其不符合红黑树规则5,因此需要进行自动平衡调整;

这里直接通过【变色】即可完成。

4.2.3 前驱为红色节点,同时子节点都为null

分析:

因为要删除的是左节点64,找到该节点的前驱63;

然后用前驱的值63替换待删除节点的值64,此时两个节点(待删除节点和前驱)的值都为63;

删除前驱63,树的结构并没有打破规则。

4.3 红黑树删除总结

红黑树删除的情况比较多,但也就存在以下情况:

删除的是根节点,则直接将根节点置为null;

待删除节点的左右子节点都为null,删除时将该节点置为null;

待删除节点的左右子节点有一个有值,则用有值的节点替换该节点即可;

待删除节点的左右子节点都不为null,则找前驱或者后继,将前驱或者后继的值复制到该节点中,然后删除前驱或者后继;

节点删除后可能会造成红黑树的不平衡,这时我们需通过【变色】+【旋转】的方式来调整,使之平衡,上面也给出了例子,建议大家多多练习,而不必背下来。

五. 总结

本文主要介绍了红黑树的相关原理,首先红黑树的基础二叉搜索树,我们先简单说了一下二叉搜索树,并且讲了一下搜索的流程,然后就针对红黑树的6大规则特点,红黑树的插入操作,删除操作,都使用了大量的图形来加以说明,技术都是练出来的,有时候很多似是而非的地方,当动笔去写的时候,其实很好理解。红黑树的使用非常广泛,如TreeMap和TreeSet都是基于红黑树实现的,而Jdk8中HashMap当链表长度大于8时也会转化为红黑树,红黑树比较复杂,本人也是还在学习过程中,如果有不对的地方请批评指正,望共同进步谢谢。