JDK 源码中 HashMap 的 hash 方法原理是什么?

JDK 的 HashMap 中使用了一个 hash 方法来做 bit shifting,在注释中说明是为了防止一些实现比较差的hashCode() 方…
关注者
974
被浏览
231,545

72 个回答

这段代码叫“扰动函数”。

题主贴的是Java 7的HashMap的源码,Java 8中这步已经简化了,只做一次16位右位移异或混合,而不是四次,但原理是不变的。下面以Java 8的源码为例解释,

//Java 8中的散列值优化函数
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);    //key.hashCode()为哈希算法,返回初始哈希值
}

大家都知道上面代码里的key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。

理论上散列值是一个int型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的int表值范围从-21474836482147483648。前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。

但问题是一个40亿长度的数组,内存是放不下的。你想,HashMap扩容之前的数组初始大小才16。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标。源码中模运算是在这个indexFor( )函数里完成的。

bucketIndex = indexFor(hash, table.length);

indexFor的代码也很简单,就是把散列值和数组长度做一个"与"操作,

static int indexFor(int h, int length) {
        return h & (length-1);
}

顺便说一下,这也正好解释了为什么HashMap的数组长度要取2的整次幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。

        10100101 11000100 00100101
&	00000000 00000000 00001111
----------------------------------
	00000000 00000000 00000101    //高位全部归零,只保留末四位

但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,就无比蛋疼。

这时候“扰动函数”的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图,

右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。

最后我们来看一下Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。

结果显示,当HashMap数组长度为512的时候,也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。

但明显Java 8觉得扰动做一次就够了,做4次的话,多了可能边际效用也不大,所谓为了效率考虑就改成一次了。


------------------------------------------------------

我的笔记栈 ciaoshen.com (笔记向,非教程)

HashMap 还是非常有意思的,远不止一个扰动函数的泊松分布,还有负载因子、拉链寻址、数据迁移等等。正好最近研究数据库路由数据散列,折腾过这部分源码,来回答一波!

一、HashCode为什么使用31作为乘数

1. 固定乘积31在这用到了

// 获取hashCode "abc".hashCode();
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

在获取hashCode的源码中可以看到,有一个固定值31,在for循环每次执行时进行乘积计算,循环后的公式如下; s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

那么这里为什么选择31作为乘积值呢?

2. 来自stackoverflow的回答

stackoverflow关于为什么选择31作为固定乘积值,有一篇讨论文章,Why does Java's hashCode() in String use 31 as a multiplier? 这是一个时间比较久的问题了,摘取两个回答点赞最多的;

413个赞 的回答

最多的这个回答是来自《Effective Java》的内容;

The value 31 was chosen because it is an odd prime. If it were even and the multiplication overflowed, information would be lost, as multiplication by 2 is equivalent to shifting. The advantage of using a prime is less clear, but it is traditional. A nice property of 31 is that the multiplication can be replaced by a shift and a subtraction for better performance: 31 * i == (i << 5) - i. Modern VMs do this sort of optimization automatically.

这段内容主要阐述的观点包括;

  1. 31 是一个奇质数。
  2. 另外在二进制中,2个5次方是32,那么也就是 31 * i == (i << 5) - i。这主要是说乘积运算可以使用位移提升性能,同时目前的JVM虚拟机也会自动支持此类的优化。

80个赞 的回答

As Goodrich and Tamassia point out, If you take over 50,000 English words (formed as the union of the word lists provided in two variants of Unix), using the constants 31, 33, 37, 39, and 41 will produce less than 7 collisions in each case. Knowing this, it should come as no surprise that many Java implementations choose one of these constants.
  • 这个回答就很有实战意义了,告诉你用超过5千个单词计算hashCode,这个hashCode的运算使用31、33、37、39和41作为乘积,得到的碰撞结果,31被使用就很正常了。
  • 他这句话就就可以作为我们实践的指向了。

3. Hash值碰撞概率统计

接下来要做的事情并不难,只是根据stackoverflow的回答,统计出不同的乘积数对10万个单词的hash计算结果。10个单词表已提供,可以通过关注公众号:bugstack虫洞栈进行下载

3.1 读取单词字典表

1 a "n.(A)As 或 A's  安(ampere(a) art.一;n.字母A /[军] Analog.Digital,模拟/数字 /(=account of) 帐上"
2 aaal American Academy of Arts and Letters 美国艺术和文学学会
3 aachen  亚琛[德意志联邦共和国西部城市]
4 aacs Airways and Air Communications Service (美国)航路与航空通讯联络处
5 aah " [军]Armored Artillery Howitzer,装甲榴弹炮;[军]Advanced Attack Helicopter,先进攻击直升机"
6 aal "ATM Adaptation Layer,ATM适应层"
7 aapamoor "n.[生]丘泽,高低位镶嵌沼泽"
  • 单词表的文件格式如上,可以自行解析
  • 读取文件的代码比较简单,这里不展示了,可以通过资源下载进行获取

3.2 Hash计算函数

public static Integer hashCode(String str, Integer multiplier) {
    int hash = 0;
    for (int i = 0; i < str.length(); i++) {
        hash = multiplier * hash + str.charAt(i);
    }
    return hash;
}
  • 这个过程比较简单,与原hash函数对比只是替换了可变参数,用于我们统计不同乘积数的计算结果。

3.3 Hash碰撞概率计算

想计算碰撞很简单,也就是计算那些出现相同哈希值的数量,计算出碰撞总量即可。这里的实现方式有很多,可以使用setmap也可以使用java8stream流统计distinct

private static RateInfo hashCollisionRate(Integer multiplier, List<Integer> hashCodeList) {
    int maxHash = hashCodeList.stream().max(Integer::compareTo).get();
    int minHash = hashCodeList.stream().min(Integer::compareTo).get();
    int collisionCount = (int) (hashCodeList.size() - hashCodeList.stream().distinct().count());
    double collisionRate = (collisionCount * 1.0) / hashCodeList.size();
    return new RateInfo(maxHash, minHash, multiplier, collisionCount, collisionRate);
}
  • 这里记录了最大hash和最小hash值,以及最终返回碰撞数量的统计结果。

3.4 单元测试

@Before
public void before() {
    "abc".hashCode();
    // 读取文件,103976个英语单词库.txt
    words = FileUtil.readWordList("E:/itstack/git/github.com/interview/interview-01/103976个英语单词库.txt");
}

@Test
public void test_collisionRate() {
    List<RateInfo> rateInfoList = HashCode.collisionRateList(words, 2, 3, 5, 7, 17, 31, 32, 33, 39, 41, 199);
    for (RateInfo rate : rateInfoList) {
        System.out.println(String.format("乘数 = %4d, 最小Hash = %11d, 最大Hash = %10d, 碰撞数量 =%6d, 碰撞概率 = %.4f%%", rate.getMultiplier(), rate.getMinHash(), rate.getMaxHash(), rate.getCollisionCount(), rate.getCollisionRate() * 100));
    }
}
  • 以上先设定读取英文单词表中的10个单词,之后做hash计算。
  • 在hash计算中把单词表传递进去,同时还有乘积数;2, 3, 5, 7, 17, 31, 32, 33, 39, 41, 199,最终返回一个list结果并输出。
  • 这里主要验证同一批单词,对于不同乘积数会有怎么样的hash碰撞结果。

测试结果

单词数量:103976
乘数 =    2, 最小Hash =          97, 最大Hash = 1842581979, 碰撞数量 = 60382, 碰撞概率 = 58.0730%
乘数 =    3, 最小Hash = -2147308825, 最大Hash = 2146995420, 碰撞数量 = 24300, 碰撞概率 = 23.3708%
乘数 =    5, 最小Hash = -2147091606, 最大Hash = 2147227581, 碰撞数量 =  7994, 碰撞概率 = 7.6883%
乘数 =    7, 最小Hash = -2147431389, 最大Hash = 2147226363, 碰撞数量 =  3826, 碰撞概率 = 3.6797%
乘数 =   17, 最小Hash = -2147238638, 最大Hash = 2147101452, 碰撞数量 =   576, 碰撞概率 = 0.5540%
乘数 =   31, 最小Hash = -2147461248, 最大Hash = 2147444544, 碰撞数量 =     2, 碰撞概率 = 0.0019%
乘数 =   32, 最小Hash = -2007883634, 最大Hash = 2074238226, 碰撞数量 = 34947, 碰撞概率 = 33.6106%
乘数 =   33, 最小Hash = -2147469046, 最大Hash = 2147378587, 碰撞数量 =     1, 碰撞概率 = 0.0010%
乘数 =   39, 最小Hash = -2147463635, 最大Hash = 2147443239, 碰撞数量 =     0, 碰撞概率 = 0.0000%
乘数 =   41, 最小Hash = -2147423916, 最大Hash = 2147441721, 碰撞数量 =     1, 碰撞概率 = 0.0010%
乘数 =  199, 最小Hash = -2147459902, 最大Hash = 2147480320, 碰撞数量 =     0, 碰撞概率 = 0.0000%

Process finished with exit code 0

以上就是不同的乘数下的hash碰撞结果图标展示,从这里可以看出如下信息;

  1. 乘数是2时,hash的取值范围比较小,基本是堆积到一个范围内了,后面内容会看到这块的展示。
  2. 乘数是3、5、7、17等,都有较大的碰撞概率
  3. 乘数是31的时候,碰撞的概率已经很小了,基本稳定。
  4. 顺着往下看,你会发现199的碰撞概率更小,这就相当于一排奇数的茅坑量多,自然会减少碰撞。但这个范围值已经远超过int的取值范围了,如果用此数作为乘数,又返回int值,就会丢失数据信息

4. Hash值散列分布

除了以上看到哈希值在不同乘数的一个碰撞概率后,关于散列表也就是hash,还有一个非常重要的点,那就是要尽可能的让数据散列分布。只有这样才能减少hash碰撞次数,也就是后面章节要讲到的hashMap源码。

那么怎么看散列分布呢?如果我们能把10万个hash值铺到图表上,形成的一张图,就可以看出整个散列分布。但是这样的图会比较大,当我们缩小看后,就成一个了大黑点。所以这里我们采取分段统计,把2 ^ 32方分64个格子进行存放,每个格子都会有对应的数量的hash值,最终把这些数据展示在图表上。

4.1 哈希值分段存放

public static Map<Integer, Integer> hashArea(List<Integer> hashCodeList) {
    Map<Integer, Integer> statistics = new LinkedHashMap<>();
    int start = 0;
    for (long i = 0x80000000; i <= 0x7fffffff; i += 67108864) {
        long min = i;
        long max = min + 67108864;
        // 筛选出每个格子里的哈希值数量,java8流统计;https://bugstack.cn/itstack-demo-any/2019/12/10/%E6%9C%89%E7%82%B9%E5%B9%B2%E8%B4%A7-Jdk1.8%E6%96%B0%E7%89%B9%E6%80%A7%E5%AE%9E%E6%88%98%E7%AF%87(41%E4%B8%AA%E6%A1%88%E4%BE%8B).html
        int num = (int) hashCodeList.parallelStream().filter(x -> x >= min && x < max).count();
        statistics.put(start++, num);
    }
    return statistics;
  • 这个过程主要统计int取值范围内,每个哈希值存放到不同格子里的数量。
  • 这里也是使用了java8的新特性语法,统计起来还是比较方便的。

4.2 单元测试

@Test
public void test_hashArea() {
    System.out.println(HashCode.hashArea(words, 2).values());
    System.out.println(HashCode.hashArea(words, 7).values());
    System.out.println(HashCode.hashArea(words, 31).values());
    System.out.println(HashCode.hashArea(words, 32).values());
    System.out.println(HashCode.hashArea(words, 199).values());
}
  • 这里列出我们要统计的乘数值,每一个乘数下都会有对应的哈希值数量汇总,也就是64个格子里的数量。
  • 最终把这些统计值放入到excel中进行图表化展示。

统计图表

  • 以上是一个堆积百分比统计图,可以看到下方是不同乘数下的,每个格子里的数据统计。
  • 除了199不能用以外,31的散列结果相对来说比较均匀。

4.2.1 乘数2散列

  • 乘数是2的时候,散列的结果基本都堆积在中间,没有很好的散列。

4.2.2 乘数31散列

  • 乘数是31的时候,散列的效果就非常明显了,基本在每个范围都有数据存放。

4.2.3 乘数199散列

  • 乘数是199是不能用的散列结果,但是它的数据是更加分散的,从图上能看到有两个小山包。但因为数据区间问题会有数据丢失问题,所以不能选择。

二、HashMap 数据结构与算法

1. 写一个最简单的HashMap

学习HashMap前,最好的方式是先了解这是一种怎么样的数据结构来存放数据。而HashMap经过多个版本的迭代后,乍一看代码还是很复杂的。就像你原来只穿个裤衩,现在还有秋裤和风衣。所以我们先来看看最根本的HashMap是什么样,也就是只穿裤衩是什么效果,之后再去分析它的源码。

**问题:**假设我们有一组7个字符串,需要存放到数组中,但要求在获取每个元素的时候时间复杂度是O(1)。也就是说你不能通过循环遍历的方式进行获取,而是要定位到数组ID直接获取相应的元素。

**方案:**如果说我们需要通过ID从数组中获取元素,那么就需要把每个字符串都计算出一个在数组中的位置ID。字符串获取ID你能想到什么方式? 一个字符串最直接的获取跟数字相关的信息就是HashCode,可HashCode的取值范围太大了[-2147483648, 2147483647],不可能直接使用。那么就需要使用HashCode与数组长度做与运算,得到一个可以在数组中出现的位置。如果说有两个元素得到同样的ID,那么这个数组ID下就存放两个字符串。

以上呢其实就是我们要把字符串散列到数组中的一个基本思路,接下来我们就把这个思路用代码实现出来。

1.1 代码实现

// 初始化一组字符串
List<String> list = new ArrayList<>();
list.add("jlkk");
list.add("lopi");
list.add("小傅哥");
list.add("e4we");
list.add("alpo");
list.add("yhjk");
list.add("plop");

// 定义要存放的数组
String[] tab = new String[8];

// 循环存放
for (String key : list) {
    int idx = key.hashCode() & (tab.length - 1);  // 计算索引位置
    System.out.println(String.format("key值=%s Idx=%d", key, idx));
    if (null == tab[idx]) {
        tab[idx] = key;
        continue;
    }
    tab[idx] = tab[idx] + "->" + key;
}
// 输出测试结果
System.out.println(JSON.toJSONString(tab));

这段代码整体看起来也是非常简单,并没有什么复杂度,主要包括以下内容;

  1. 初始化一组字符串集合,这里初始化了7个。
  2. 定义一个数组用于存放字符串,注意这里的长度是8,也就是2的倍数。这样的数组长度才会出现一个 0111 除高位以外都是1的特征,也是为了散列。
  3. 接下来就是循环存放数据,计算出每个字符串在数组中的位置。key.hashCode() & (tab.length - 1)
  4. 在字符串存放到数组的过程,如果遇到相同的元素,进行连接操作模拟链表的过程
  5. 最后输出存放结果。

测试结果

key值=jlkk Idx=2
key值=lopi Idx=4
key值=小傅哥 Idx=7
key值=e4we Idx=5
key值=alpo Idx=2
key值=yhjk Idx=0
key值=plop Idx=5
测试结果:["yhjk",null,"jlkk->alpo",null,"lopi","e4we->plop",null,"小傅哥"]
  • 在测试结果首先是计算出每个元素在数组的Idx,也有出现重复的位置。
  • 最后是测试结果的输出,1、3、6,位置是空的,2、5,位置有两个元素被链接起来e4we->plop
  • 这就达到了我们一个最基本的要求,将串元素散列存放到数组中,最后通过字符串元素的索引ID进行获取对应字符串。这样是HashMap的一个最基本原理,有了这个基础后面就会更容易理解HashMap的源码实现。

1.2 Hash散列示意图

如果上面的测试结果不能在你的头脑中很好的建立出一个数据结构,那么可以看以下这张散列示意图,方便理解;

  • 这张图就是上面代码实现的全过程,将每一个字符串元素通过Hash计算索引位置,存放到数组中。
  • 黄色的索引ID是没有元素存放、绿色的索引ID存放了一个元素、红色的索引ID存放了两个元素。

1.3 这个简单的HashMap有哪些问题

以上我们实现了一个简单的HashMap,或者说还算不上HashMap,只能算做一个散列数据存放的雏形。但这样的一个数据结构放在实际使用中,会有哪些问题呢?

  1. 这里所有的元素存放都需要获取一个索引位置,而如果元素的位置不够散列碰撞严重,那么就失去了散列表存放的意义,没有达到预期的性能。
  2. 在获取索引ID的计算公式中,需要数组长度是2的倍数,那么怎么进行初始化这个数组大小。
  3. 数组越小碰撞的越大,数组越大碰撞的越小,时间与空间如何取舍。
  4. 目前存放7个元素,已经有两个位置都存放了2个字符串,那么链表越来越长怎么优化。
  5. 随着元素的不断添加,数组长度不足扩容时,怎么把原有的元素,拆分到新的位置上去。

以上这些问题可以归纳为;扰动函数初始化容量负载因子扩容方法以及链表和红黑树转换的使用等。接下来我们会逐个问题进行分析。

2. 扰动函数

在HashMap存放元素时候有这样一段代码来处理哈希值,这是java 8的散列值扰动函数,用于优化散列效果;

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

2.1 为什么使用扰动函数

理论上来说字符串的hashCode是一个int类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个hashCode的取值范围是[-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。

我们默认初始化的Map大小是16个长度 DEFAULT_INITIAL_CAPACITY = 1 << 4,所以获取的Hash值并不能直接作为下标使用,需要与数组长度进行取模运算得到一个下标值,也就是我们上面做的散列列子。

那么,hashMap源码这里不只是直接获取哈希值,还进行了一次扰动计算,(h = key.hashCode()) ^ (h >>> 16)。把哈希值右移16位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性。计算方式如下图;

  • 说白了,使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。

2.2 实验验证扰动函数

从上面的分析可以看出,扰动函数使用了哈希值的高半区和低半区做异或,混合原始哈希码的高位和低位,以此来加大低位区的随机性。

但看不到实验数据的话,这终究是一段理论,具体这段哈希值真的被增加了随机性没有,并不知道。所以这里我们要做一个实验,这个实验是这样做;

  1. 选取10万个单词词库
  2. 定义128位长度的数组格子
  3. 分别计算在扰动和不扰动下,10万单词的下标分配到128个格子的数量
  4. 统计各个格子数量,生成波动曲线。如果扰动函数下的波动曲线相对更平稳,那么证明扰动函数有效果。

2.2.1 扰动代码测试

扰动函数对比方法

public class Disturb {

    public static int disturbHashIdx(String key, int size) {
        return (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16));
    }

    public static int hashIdx(String key, int size) {
        return (size - 1) & key.hashCode();
    }

}
  • disturbHashIdx 扰动函数下,下标值计算
  • hashIdx 非扰动函数下,下标值计算

单元测试

// 10万单词已经初始化到words中
@Test
public void test_disturb() {
    Map<Integer, Integer> map = new HashMap<>(16);
    for (String word : words) {
        // 使用扰动函数
        int idx = Disturb.disturbHashIdx(word, 128);
        // 不使用扰动函数
        // int idx = Disturb.hashIdx(word, 128);
        if (map.containsKey(idx)) {
            Integer integer = map.get(idx);
            map.put(idx, ++integer);
        } else {
            map.put(idx, 1);
        }
    }
    System.out.println(map.values());
}

以上分别统计两种函数下的下标值分配,最终将统计结果放到excel中生成图表。

2.2.2 扰动函数散列图表

以上的两张图,分别是没有使用扰动函数和使用扰动函数的,下标分配。实验数据;

  1. 10万个不重复的单词
  2. 128个格子,相当于128长度的数组

未使用扰动函数

使用扰动函数

  • 从这两种的对比图可以看出来,在使用了扰动函数后,数据分配的更加均匀了。
  • 数据分配均匀,也就是散列的效果更好,减少了hash的碰撞,让数据存放和获取的效率更佳。

3. 初始化容量和负载因子

接下来我们讨论下一个问题,从我们模仿HashMap的例子中以及HashMap默认的初始化大小里,都可以知道,散列数组需要一个2的倍数的长度,因为只有2的倍数在减1的时候,才会出现01111这样的值。

那么这里就有一个问题,我们在初始化HashMap的时候,如果传一个17个的值new HashMap<>(17);,它会怎么处理呢?

3.1 寻找2的倍数最小值

在HashMap的初始化中,有这样一段方法;

public HashMap(int initialCapacity, float loadFactor) {
    ...
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}
  • 阀值threshold,通过方法tableSizeFor进行计算,是根据初始化来计算的。
  • 这个方法也就是要寻找比初始值大的,最小的那个2进制数值。比如传了17,我应该找到的是32。

计算阀值大小的方法;

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;
}
  • MAXIMUM_CAPACITY = 1 << 30,这个是临界范围,也就是最大的Map集合。
  • 乍一看可能有点晕 怎么都在向右移位1、2、4、8、16,这主要是为了把二进制的各个位置都填上1,当二进制的各个位置都是1以后,就是一个标准的2的倍数减1了,最后把结果加1再返回即可。

那这里我们把17这样一个初始化计算阀值的过程,用图展示出来,方便理解;

3.2 负载因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

负载因子是做什么的?

负载因子,可以理解成一辆车可承重重量超过某个阀值时,把货放到新的车上。

那么在HashMap中,负载因子决定了数据量多少了以后进行扩容。这里要提到上面做的HashMap例子,我们准备了7个元素,但是最后还有3个位置空余,2个位置存放了2个元素。 所以可能即使你数据比数组容量大时也是不一定能正正好好的把数组占满的,而是在某些小标位置出现了大量的碰撞,只能在同一个位置用链表存放,那么这样就失去了Map数组的性能。

所以,要选择一个合理的大小下进行扩容,默认值0.75就是说当阀值容量占了3/4s时赶紧扩容,减少Hash碰撞。

同时0.75是一个默认构造值,在创建HashMap也可以调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。

4. 扩容元素拆分

为什么扩容,因为数组长度不足了。那扩容最直接的问题,就是需要把元素拆分到新的数组中。拆分元素的过程中,原jdk1.7中会需要重新计算哈希值,但是到jdk1.8中已经进行优化,不在需要重新计算,提升了拆分的性能,设计的还是非常巧妙的。

4.1 测试数据

@Test
public void test_hashMap() {
    List<String> list = new ArrayList<>();
    list.add("jlkk");
    list.add("lopi");
    list.add("jmdw");
    list.add("e4we");
    list.add("io98");
    list.add("nmhg");
    list.add("vfg6");
    list.add("gfrt");
    list.add("alpo");
    list.add("vfbh");
    list.add("bnhj");
    list.add("zuio");
    list.add("iu8e");
    list.add("yhjk");
    list.add("plop");
    list.add("dd0p");
    for (String key : list) {
        int hash = key.hashCode() ^ (key.hashCode() >>> 16);
        System.out.println("字符串:" + key + " \tIdx(16):" + ((16 - 1) & hash) + " \tBit值:" + Integer.toBinaryString(hash) + " - " + Integer.toBinaryString(hash & 16) + " \t\tIdx(32):" + ((
        System.out.println(Integer.toBinaryString(key.hashCode()) +" "+ Integer.toBinaryString(hash) + " " + Integer.toBinaryString((32 - 1) & hash));
    }
}

测试结果

字符串:jlkk  Idx(16):3  Bit值:1100011101001000010011 - 10000   Idx(32):19
1100011101001000100010 1100011101001000010011 10011
字符串:lopi  Idx(16):14  Bit值:1100101100011010001110 - 0   Idx(32):14
1100101100011010111100 1100101100011010001110 1110
字符串:jmdw  Idx(16):7  Bit值:1100011101010100100111 - 0   Idx(32):7
1100011101010100010110 1100011101010100100111 111
字符串:e4we  Idx(16):3  Bit值:1011101011101101010011 - 10000   Idx(32):19
1011101011101101111101 1011101011101101010011 10011
字符串:io98  Idx(16):4  Bit值:1100010110001011110100 - 10000   Idx(32):20
1100010110001011000101 1100010110001011110100 10100
字符串:nmhg  Idx(16):13  Bit值:1100111010011011001101 - 0   Idx(32):13
1100111010011011111110 1100111010011011001101 1101
字符串:vfg6  Idx(16):8  Bit值:1101110010111101101000 - 0   Idx(32):8
1101110010111101011111 1101110010111101101000 1000
字符串:gfrt  Idx(16):1  Bit值:1100000101111101010001 - 10000   Idx(32):17
1100000101111101100001 1100000101111101010001 10001
字符串:alpo  Idx(16):7  Bit值:1011011011101101000111 - 0   Idx(32):7
1011011011101101101010 1011011011101101000111 111
字符串:vfbh  Idx(16):1  Bit值:1101110010111011000001 - 0   Idx(32):1
1101110010111011110110 1101110010111011000001 1
字符串:bnhj  Idx(16):0  Bit值:1011100011011001100000 - 0   Idx(32):0
1011100011011001001110 1011100011011001100000 0
字符串:zuio  Idx(16):8  Bit值:1110010011100110011000 - 10000   Idx(32):24
1110010011100110100001 1110010011100110011000 11000
字符串:iu8e  Idx(16):8  Bit值:1100010111100101101000 - 0   Idx(32):8
1100010111100101011001 1100010111100101101000 1000
字符串:yhjk  Idx(16):8  Bit值:1110001001010010101000 - 0   Idx(32):8
1110001001010010010000 1110001001010010101000 1000
字符串:plop  Idx(16):9  Bit值:1101001000110011101001 - 0   Idx(32):9
1101001000110011011101 1101001000110011101001 1001
字符串:dd0p  Idx(16):14  Bit值:1011101111001011101110 - 0   Idx(32):14
1011101111001011000000 1011101111001011101110 1110
  • 这里我们随机使用一些字符串计算他们分别在16位长度和32位长度数组下的索引分配情况,看哪些数据被重新路由到了新的地址。
  • 同时,这里还可以观察 出一个非常重要的信息,原哈希值与扩容新增出来的长度16,进行&运算,如果值等于0,则下标位置不变。如果不为0,那么新的位置则是原来位置上加16。{这个地方需要好好理解下,并看实验数据}
  • 这样一来,就不需要在重新计算每一个数组中元素的哈希值了。

4.2 数据迁移

  • 这张图就是原16位长度数组元素,像32位数组长度中转移的过程。
  • 其中黄色区域元素zuio因计算结果 hash & oldCap 不为1,则被迁移到下标位置24。
  • 同时还是用重新计算哈希值的方式验证了,确实分配到24的位置,因为这是在二进制计算中补1的过程,所以可以通过上面简化的方式确定哈希值的位置。

三、HashMap源码解析

1. 插入

1.1 疑问点&考题

通过上一章节的学习:《HashMap核心知识,扰动函数、负载因子、扩容链表拆分,深度学习》

大家对于一个散列表数据结构的HashMap往里面插入数据时,基本已经有了一个印象。简单来说就是通过你的Key值取得哈希再计算下标,之后把相应的数据存放到里面。

但再这个过程中会遇到一些问题,比如;

  1. 如果出现哈希值计算的下标碰撞了怎么办?
  2. 如果碰撞了是扩容数组还是把值存成链表结构,让一个节点有多个值存放呢?
  3. 如果存放的数据的链表过长,就失去了散列表的性能了,怎么办呢?
  4. 如果想解决链表过长,什么时候使用树结构呢,使用哪种树呢?

这些疑问点都会在后面的内容中逐步讲解,也可以自己思考一下,如果是你来设计,你会怎么做。

1.2 插入流程和源码分析

HashMap插入数据流程图

visio原版流程图,可以通过关注公众号:bugstack虫洞栈,进行下载

以上就是HashMap中一个数据插入的整体流程,包括了;计算下标、何时扩容、何时链表转红黑树等,具体如下;

  1. 首先进行哈希值的扰动,获取一个新的哈希值。(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  2. 判断tab是否位空或者长度为0,如果是则进行扩容操作。
    if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

  3. 根据哈希值计算下标,如果对应小标正好没有存放数据,则直接插入即可否则需要覆盖。tab[i = (n - 1) & hash])
  4. 判断tab[i]是否为树节点,否则向链表中插入数据,是则向树中插入节点。
  5. 如果链表中插入节点的时候,链表长度大于等于8,则需要把链表转换为红黑树。treeifyBin(tab, hash);
  6. 最后所有元素处理完成后,判断是否超过阈值;threshold,超过则扩容。
  7. treeifyBin,是一个链表转树的方法,但不是所有的链表长度为8后都会转成树,还需要判断存放key值的数组桶长度是否小于64 MIN_TREEIFY_CAPACITY。如果小于则需要扩容,扩容后链表上的数据会被拆分散列的相应的桶节点上,也就把链表长度缩短了。

JDK1.8 HashMap的put方法源码如下:

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 onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 初始化桶数组 table,table 被延迟到插入新数据时再进行初始化
    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;
        // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            
        // 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法
        else if (p instanceof TreeNode)  
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 对链表进行遍历,并统计链表长度
            for (int binCount = 0; ; ++binCount) {
                // 链表中不包含要插入的键值对节点时,则将该节点接在链表的最后
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 如果链表长度大于或等于树化阈值,则进行树化操作
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                
                // 条件为 true,表示当前链表包含要插入的键值对,终止遍历
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 判断要插入的键值对是否存在 HashMap 中
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 键值对数量超过阈值时,则进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

1.3 扩容机制

HashMap是基于数组+链表和红黑树实现的,但用于存放key值得的数组桶的长度是固定的,由初始化决定。

那么,随着数据的插入数量增加以及负载因子的作用下,就需要扩容来存放更多的数据。而扩容中有一个非常重要的点,就是jdk1.8中的优化操作,可以不需要再重新计算每一个元素的哈希值,这在上一章节中已经讲到,可以阅读系列专题文章,机制如下图;

里我们主要看下扩容的代码(注释部分);

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // Cap 是 capacity 的缩写,容量。如果容量不为空,则说明已经初始化。
    if (oldCap > 0) {
        // 如果容量达到最大1 << 30则不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        
        // 按旧容量和阀值的2倍计算新容量和阀值
        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 threshold
    
        // initial capacity was placed in threshold 翻译过来的意思,如下;
        // 初始化时,将 threshold 的值赋值给 newCap,
        // HashMap 使用 threshold 变量暂时保存 initialCapacity 参数的值
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        // 这一部分也是,源代码中也有相应的英文注释
        // 调用无参构造方法时,数组桶数组容量为默认容量 1 << 4; aka 16
        // 阀值;是默认容量与负载因子的乘积,0.75
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // newThr为0,则使用阀值公式计算容量
    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"})
        // 初始化数组桶,用于存放key
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 如果旧数组桶,oldCap有值,则遍历将键值映射到新数组桶中
        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)
                    // 这里split,是红黑树拆分操作。在重新映射时操作的。
                    ((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;
                    // 这里是链表,如果当前是按照链表存放的,则将链表节点按原顺序进行分组{这里有专门的文章介绍,如何不需要重新计算哈希值进行拆分《HashMap核心知识,扰动函数、负载因子、扩容链表拆分,深度学习》}
                    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;
}

以上的代码稍微有些长,但是整体的逻辑还是蛮清晰的,主要包括;

  1. 扩容时计算出新的newCap、newThr,这是两个单词的缩写,一个是Capacity ,另一个是阀Threshold
  2. newCap用于创新的数组桶 new Node[newCap];
  3. 随着扩容后,原来那些因为哈希碰撞,存放成链表和红黑树的元素,都需要进行拆分存放到新的位置中。

1.4 链表树化

HashMap这种散列表的数据结构,最大的性能在于可以O(1)时间复杂度定位到元素,但因为哈希碰撞不得已在一个下标里存放多组数据,那么jdk1.8之前的设计只是采用链表的方式进行存放,如果需要从链表中定位到数据时间复杂度就是O(n),链表越长性能越差。因为在jdk1.8中把过长的链表也就是8个,优化为自平衡的红黑树结构,以此让定位元素的时间复杂度优化近似于O(logn),这样来提升元素查找的效率。但也不是完全抛弃链表,因为在元素相对不多的情况下,链表的插入速度更快,所以综合考虑下设定阈值为8才进行红黑树转换操作。

链表转红黑树,如下图;

以上就是一组链表转换为红黑树的情况,元素包括;40、51、62、73、84、95、150、161 这些是经过实际验证可分配到Idx:12的节点

通过这张图,基本可以有一个链表换行到红黑树的印象,接下来阅读下对应的源码。

链表树化源码

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 这块就是我们上面提到的,不一定树化还可能只是扩容。主要桶数组容量是否小于64 MIN_TREEIFY_CAPACITY 
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
     // 又是单词缩写;hd = head (头部),tl = tile (结尾)
        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);
    }
}

这一部分链表树化的操作并不复杂,复杂点在于下一层的红黑树转换上,这部分知识点会在后续章节中专门介绍;

以上源码主要包括的知识点如下;

  1. 链表树化的条件有两点;链表长度大于等于8、桶容量大于64,否则只是扩容,不会树化。
  2. 链表树化的过程中是先由链表转换为树节点,此时的树可能不是一颗平衡树。同时在树转换过程中会记录链表的顺序,tl.next = p,这主要方便后续树转链表和拆分更方便。
  3. 链表转换成树完成后,在进行红黑树的转换。先简单介绍下,红黑树的转换需要染色和旋转,以及比对大小。在比较元素的大小中,有一个比较有意思的方法,tieBreakOrder加时赛,这主要是因为HashMap没有像TreeMap那样本身就有Comparator的实现。

1.5 红黑树转链

在链表转红黑树中我们重点介绍了一句,在转换树的过程中,记录了原有链表的顺序。

那么,这就简单了,红黑树转链表时候,直接把TreeNode转换为Node即可,源码如下;

final Node<K,V> untreeify(HashMap<K,V> map) {
    Node<K,V> hd = null, tl = null;
    // 遍历TreeNode
    for (Node<K,V> q = this; q != null; q = q.next) {
     // TreeNode替换Node
        Node<K,V> p = map.replacementNode(q, null);
        if (tl == null)
            hd = p;
        else
            tl.next = p;
        tl = p;
    }
    return hd;
}

// 替换方法
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
    return new Node<>(p.hash, p.key, p.value, next);
}

因为记录了链表关系,所以替换过程很容易。所以好的数据结构可以让操作变得更加容易。

2. 查找

上图就是HashMap查找的一个流程图,还是比较简单的,同时也是高效的。

接下来我们在结合代码,来分析这段流程,如下;

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;
    // 判断桶数组的是否为空和长度值
    if ((tab = table) != null && (n = tab.length) > 0 &&
        // 计算下标,哈希值与数组长度-1
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // TreeNode 节点直接调用红黑树的查找方法,时间复杂度O(logn)
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, 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. 扰动函数的使用,获取新的哈希值,这在上一章节已经讲过
  2. 下标的计算,同样也介绍过 tab[(n - 1) & hash])
  3. 确定了桶数组下标位置,接下来就是对红黑树和链表进行查找和遍历操作了

3. 删除

public V remove(Object key) {
     Node<K,V> e;
     return (e = removeNode(hash(key), key, null, false, true)) == null ?
         null : e.value;
 }
 
final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    // 定位桶数组中的下标位置,index = (n - 1) & hash
    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;
        // 如果键的值与链表第一个节点相等,则将 node 指向该节点
        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);
            }
        }
        
        // 删除节点,以及红黑树需要修复,因为删除后会破坏平衡性。链表的删除更加简单。
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
} 
  • 删除的操作也比较简单,这里面都没有太多的复杂的逻辑。
  • 另外红黑树的操作因为被包装了,只看使用上也是很容易。

4. 遍历

4.1 问题点

HashMap中的遍历也是非常常用的API方法,包括;

KeySet

for (String key : map.keySet()) {
     System.out.print(key + " ");
 }

EntrySet

for (HashMap.Entry entry : map.entrySet()) {
     System.out.print(entry + " ");
 }

从方法上以及日常使用都知道,KeySet是遍历是无序的,但每次使用不同方式遍历包括keys.iterator(),它们遍历的结果是固定的。

那么从实现的角度来看,这些种遍历都是从散列表中的链表和红黑树获取集合值,那么他们有一个什么固定的规律吗?

4.2 用代码测试

测试的场景和前提;

  1. 这里我们要设定一个既有红黑树又有链表结构的数据场景
  2. 为了可以有这样的数据结构,我们最好把HashMap的初始长度设定为64,避免在链表超过8位后扩容,而是直接让其转换为红黑树。
  3. 找到18个元素,分别放在不同节点(这些数据通过程序计算得来);
    1. 桶数组02节点:24、46、68
    2. 桶数组07节点:29
    3. 桶数组12节点:150、172、194、271、293、370、392、491、590

代码测试

@Test
public void test_Iterator() {
    Map<String, String> map = new HashMap<String, String>(64);
    map.put("24", "Idx:2");
    map.put("46", "Idx:2");
    map.put("68", "Idx:2");
    map.put("29", "Idx:7");
    map.put("150", "Idx:12");
    map.put("172", "Idx:12");
    map.put("194", "Idx:12");
    map.put("271", "Idx:12");
    System.out.println("排序01:");
    for (String key : map.keySet()) {
        System.out.print(key + " ");
    }
    
    map.put("293", "Idx:12");
    map.put("370", "Idx:12");
    map.put("392", "Idx:12");
    map.put("491", "Idx:12");
    map.put("590", "Idx:12");
    System.out.println("\n\n排序02:");
    for (String key : map.keySet()) {
        System.out.print(key + " ");
    }    
    
    map.remove("293");
    map.remove("370");
    map.remove("392");
    map.remove("491");
    map.remove("590");
    System.out.println("\n\n排序03:");
    for (String key : map.keySet()) {
        System.out.print(key + " ");
    }
    
}

这段代码分别测试了三种场景,如下;

  1. 添加元素,在HashMap还是只链表结构时,输出测试结果01
  2. 添加元素,在HashMap转换为红黑树时候,输出测试结果02
  3. 删除元素,在HashMap转换为链表结构时,输出测试结果03

4.3 测试结果分析

排序01:
24 46 68 29 150 172 194 271 

排序02:
24 46 68 29 271 150 172 194 293 370 392 491 590 

排序03:
24 46 68 29 172 271 150 194 
Process finished with exit code 0

从map.keySet()测试结果可以看到,如下信息;

  1. 01情况下,排序定位哈希值下标和链表信息
  1. 02情况下,因为链表转换为红黑树,树根会移动到数组头部。moveRootToFront()方法
  1. 03情况下,因为删除了部分元素,红黑树退化成链表。

四、红黑树前身,2-3树分析

日常的学习和一部分伙伴的面试中,竟然会听 到的是;从HashMap中文红黑树、从数据库索引为B+Tree,但问2-3树的情况就不是很多了。

1. 为什么使用树结构

从最根本的原因来看,使用树结构就是为了提升整体的效率;插入、删除、查找(索引),尤其是索引操作。因为相比于链表,一个平衡树的索引时间复杂度是O(logn),而数组的索引时间复杂度是O(n)。

从以下的图上可以对比,两者的索引耗时情况;

  • 从上图可以看到,使用树结构有效的降低时间复杂度,提升数据索引效率。
  • 另外这个标准的树结构,是二叉搜索树(Binary Search Tree)。除此之外树形结构还有;AVL树、红黑树、2-3树等

2. 二叉搜索树退化链表

在树的数据结构中,最先有点是二叉查找树,也就是英文缩写BST树。在使用数据插入的过程中,理想情况下它是一个平衡的二叉树,但实际上可能会出现二叉树都一边倒,让二叉树像列表一样的数据结构。从而树形结构的时间复杂度也从O(logn)升级到O(n),如下图;

  • 二叉搜索树的数据插入过程是,插入节点与当前树节点做比对,小于在左,大于在右。
  • 随着数据的插入顺序不同,就会出现完全不同的数据结构。可能是一棵平衡二叉树,也极有可能退化成链表的树。
  • 当树结构退化成链表以后,整个树索引的性能也跟着退化成链表。

综上呢,如果我们希望在插入数据后又保持树的特点,O(logn)的索引性能,那么就需要在插入时进行节点的调整

3. 2-3树解决平衡问题

2-3树是什么结构,它怎么解决平衡问题的。带着问题我们继续 。

2-3树是一种非常巧妙的结构,在保持树结构的基础上,它允许在一个节点中可以有两个元素,等元素数量等于3个时候再进行调整。通过这种方式呢,来保证整个二叉搜索树的平衡性。

这样说可能还没有感觉,来看下图;

  • 左侧是二叉搜索树,右侧是2-3平衡树,分别插入节点4、5,观察树形结构变化。
  • 二叉搜索树开始出现偏移,节点一遍倒。
  • 2-3树通过一个节点中存放2到3个元素,来调整树形结构,保持平衡。所谓的保持平衡就是从根节点,到每一个最底部的自己点,链路长度一致。

2-3树已经可以解决平衡问题那么,数据是怎么存放和调整的呢,接下来我们开始实践使用。

五、红黑树前身,2-3树使用

1. 树结构定义和特点性质

2-3树,读法;二三树,特性如下;

序号描述示意图

综上我们可以总结出,2-3树的一些性质;

  1. 2-3树所有子叶节点都在同一层
  2. 1个节点可以有1到2个数据,如果有三个需要调整树结构
  3. 1个节点1个数据时,则有两个子节点
  4. 1个节点2个数据时,则有三个子节点,且中间子节点是介于两个节点间的值

2. 数据插入

接下来我们就模拟在二叉搜索树中退化成链表的数据,插入到2-3树的变化过程,数据包括;1、2、3、4、5、6、7,插入过程图如下;

以上,就是整个数据在插入过程中,2-3树的演化过程,接下来我们具体讲解每一步的变化;

  • α,向节点1插入数据2,此时为了保持平衡,不会新产生分支,只会在一个节点中存放两个节点。
  • β,继续插入数据3,此时这个节点有三数据,1、2、3,是一个临时区域。
  • γ,把三个数据的节点,中间节点拉起来,调整成树形结构。
  • δ,继续插入数据4,为了保持树平衡,会插在节点3的右侧。
  • ε,继续插入数据5,插入后3、4、5共用1个节点,当一个节点上有三个数据时候,则需要进行调整。
  • ζ,中间节点4向上⏫调整,调整后,1节点在左、3节点在中间、5节点在右。
  • η ,继续插入数据6,在保持树平衡的情况下,与节点5公用。
  • θ ,继续插入数据7,插入后,节点7会与当前的节点 5 6 共用。此时是一个临时存放,需要调整。初步调整后,抽出6节点,向上存放,变为2 4 6共用一个节点,这是一个临时状态,还需要继续调整。
  • ι,因为根节点有三个数据2、4、6,则继续需要把中间节点上移,1、35、7 则分别成二叉落到节点2节点6上。

希腊字母:α(阿尔法)、 β(贝塔)、γ(伽马)、δ(德尔塔)、ε(伊普西隆)、ζ(截塔)、η(艾塔)、θ(西塔)、ι(约塔)

3. 数据删除

有了上面数据插入的学习,在看数据删除其实就是一个逆向的过程,在删除的主要包括这样两种情况;

  1. 删除了3-节点,也就是包含两个数据元素的节点,直接删除即可,不会破坏树平衡。
  2. 删除了2-节点,此时会破坏树平衡,需要将树高缩短或者元素合并,恢复树平衡。

承接上面 的例子,我们把数据再从7、6、5、4、3、2、1顺序删除,观察2-3树的结构变化,如下;

  • α,删除节点7,因为节点7只有一个数据元素,删除节点5、6合并,但此时破坏了2-3树的平衡性,需要缩短树高进行调整。
  • β,因为删除节点后,整个树结构不平衡,所以需要缩短树高,调整元素。节点2、4合并,节点1、3分别插入左侧和中间。
  • γ,删除节点6,这个节点是3-节点(可以分出3个叉的意思),删除后不会破坏树平衡,保持不变。
  • δ,删除节点5,此时会破坏树平衡,需要把跟节点4下放,与3合并。
  • ε,删除节点4,这个节点依旧是3-节点,所以不需要改变树结构。
  • ζ,删除节点3,此时只有1、2节点,需要合并。
  • η ,删除节点2,此时节点依旧是3-节点,所以不需要改变树结构。

再看一个稍微复杂点2-3树删除:

上面 这张图,就一个稍微复杂点的2-3平衡树,树的删除过程主要包括;

  1. 删除4,其实需要将节点3、5合并,指向节点2,保持树平衡。
  2. 删除7,节点8、9合并。
  3. 删除14,节点15上移,恢复成3-叉树。

如果有时候不好理解删除,可以试想下,这个要删除的节点,在插入的时候是一个什么效果。

4. 数据索引

相比于插入和删除,索引的过程还是比较简单的,不需要调整数据结果。基本原则就是;

  1. 小于当前节点值,左侧寻找
  2. 大于当前节点值,右侧寻找
  3. 一直到找到索引值,停止。

第一层寻找:

第二层寻找:

第三次寻找:

六、红黑树解析

红黑树,是一种高效的自平衡二叉查找树

Rudolf Bayer 于1978年发明红黑树,在当时被称为对称二叉 B 树(symmetric binary B-trees)。后来,在1978年被 Leo J. Guibas 和 Robert Sedgewick 修改为如今的红黑树

红黑树具有良好的效率,它可在近似O(logN) 时间复杂度下完成插入、删除、查找等操作,因此红黑树在业界也被广泛应用,比如 Java 中的 TreeMap,JDK 1.8 中的 HashMap、C++ STL 中的 map 均是基于红黑树结构实现的。

死记硬背,很难学会

红黑树的结构和设计都非常优秀,也同样在实现上有着复杂的处理逻辑,包括插入或者删除节点时;颜色变化、旋转操作等操作。但如果只把这些知识点硬背下来,什么时候染色、什么时候旋转,是没有多大意义的,用不了多久也就忘记了。所以这部分的学习,了解其根本更重要。

1、2-3树与红黑树的等价性

在上一章节《讲解2-3平衡树「红黑树的前身」》,使用了大量图例讲解了2-3树,并在标题处写出它是红黑树的前身。阅读后更容易理解红黑树相关知识。

红黑树规则

1. 根节点是黑色
2. 节点是红黑或者黑色
3. 所有子叶节点都是黑色(叶子是NIL节点,默认没有画出来)
4. 每个红色节点必须有两个黑色子节点(也同样说明一条链路上不能有链路的红色节点)
5. 黑高,从任一节点到齐每个叶子节点,经过的路径都包含相同数目的黑色节点

那么,这些规则是怎么总结定义出来的呢?接下里我们一步步分析讲解。

1. 为什么既有2-3树要有红黑树

首先2-3树(读法:二三树)就是一个节点有1个或者2个元素,而实际上2-3树转红黑树是由概念模型2-3-4树转换而来的。-4叉就是一个节点里有3个元素,这在2-3树中会被调整,但是在概念模型中是会被保留的。

虽然2-3-4树也是具备2-3树同样的平衡树的特性,但是如果直接把这样的模型用代码实现就会很麻烦,且效率不高,这里的复杂点包括;

  1. 2-叉、3-叉、4-叉,三种结构的节点类型,互相转换复杂度较高
  2. 3-叉、4-叉,节点在数据比较上需要进行多次,不像2-叉节点,直接布尔类型比较即可非左即右
  3. 代码实现上对每种差异,都需要有额外的代码,规则不够标准化

所以,希望找到一种平衡关系,既保持2-3树平衡和O(logn)的特性,又能在代码实现上更加方便,那么就诞生了红黑树。

2. 简单2-3树转红黑树

2-3树转红黑树,也可以说红黑树是2-3树2-3-4树的另外一种表现形式,也就是更利于编码实现的形式。

简单转换示例;

从上图可以看出,2-3-4树与红黑树的转换关系,包括;

  1. 2-叉节点,转换比较简单,只是把原有节点转换为黑色节点
  2. 3-叉节点,包括了2个元素,先用红色线把两个节点相连,之后拆分出来,最后调整高度黑色节点在上
  3. 4-叉节点,包括了3个元素,分别用红黑线连接,之后拆分出来拉升高度。这个拉升过程和2-3树调整一致,只是添加了颜色

综上,就是2-3-4树的节点转换,总结出来的规则,如下;

  1. 将2-3-4树,用二叉树的形式表示
  2. 3-叉、4-叉节点,使用红色、黑色连线进行连接
  3. 另外,3-叉节点有两种情况,导致转换成二叉树,就有左倾和右倾

3. 复杂2-3树转红黑树

简单2-3树转换红黑树的过程中,了解到一个基本的转换规则右旋定义,接下来我们在一个稍微复杂一点的2-3树与红黑树的对应关系,如下图;

上图是一个稍微复杂点的2-3树,转换为红黑树的过程,是不这样一张图让你对红黑树更有感觉了,同时它也满足一下条件;

  1. 从任意节点到叶子节点,所经过的黑色节点数目相同
  2. 黑色节点保持着整体的平衡性,也就是让整个红黑树接近于O(logn)时间复杂度
  3. 其他红黑树的特点也都满足,可以对照红黑树的特性进行比对

2、红黑树操作

2.1 平衡操作

通过在上一章节2-3树的学习,在插入节点时并不会插到空位置,而是与现有节点融合以及调整,保持整个树的平衡。

而红黑树是2-3-4树的一种概念模型转换而来,在插入节点时通过红色链接相连,也就是插入红色节点。插入完成后进行调整,以保持树接近平衡。

那么,为了让红黑树达到平衡状态,主要包括染色、↔左右旋转、这些做法其实都是从2-3树演化过来的。接下来我们就分别讲解几种规则的演化过程,以此更好了解红黑树的平衡操作。

2.1.1 左旋转

左旋定义: 把一个向右倾斜的红节点链接(2-3树,3-叉双元素节点),转化为左链接。

背景:顺序插入元素,1、2、3,2-3树保持平衡,红黑树暂时处于右倾斜。

接下来我们分别对比两种树结构的平衡操作;

  1. 2-3树,所有插入的节点都会保持在一个节点上,之后通过调整节点位置,保持平衡。
  2. 红黑树,则需要通过节点的左侧旋转,将元素2拉起来,元素1和元素3,分别成为左右子节点。

红黑树的左旋,只会处理与之对应的2-3树节点进行操作,不会整体改变。

2.1.2 右旋转

右旋定义: 把一个向左倾斜的红节点连接(2-3树,3-叉双元素节点),转换为右连接。

背景:顺序插入元素,3、1、1,2-3树保持平衡,红黑树暂时处于左倾斜。

接下来我们分别对比两种树结构的平衡操作;

  1. 2-3树,所有插入的节点都会保持在一个节点上,之后通过调整节点位置,保持平衡。
  2. 红黑树,则需要通过节点的右侧旋转,将元素2拉起来,元素1和元素3,分别成为左右子节点。

你会发现,左旋与右旋是相互对应的,但在2-3树中是保持不变的

2.1.3 左右旋综合运用

左旋、右旋,我们已经有了一个基本的概念,那么接下来我们再看一个可以综合左右旋以及对应2-3树的演化案例,如下;

以上的例子分别演示了一个元素插入的三种情况,如下;

  1. 1、3,插入0,左侧底部插入,与2-3树相比,需要右旋保持平衡
  2. 1、3,插入2,中间位置插入,首先进行左旋调整元素位置,之后进行右旋进行树平衡
  3. 1、3,插入5,右侧位置插入,此时正好保持树平衡,不需要调整

2.1.4 染色

在2-3树中,插入一个节点,为了保持树平衡是不插入到空位置上的,当插入节点后元素数量有3个后则需要调整中间元素向上,来保持树平衡。与之对应的红黑树则需要调整颜色,来保证红黑树的平衡规则,具体参考如下;

2.2 旋转+染色运用案例

接下来我们把上面讲解到的旋转染色,运用到一个实际案例中,如下图;

  • 首先从左侧开始,是一个按照顺序插入生产出来的红黑树,插入顺序;7、2、8、1、4、3、5
  • α,向目前红黑树插入元素6,插入后右下角有三个红色节点;3、5、6
  • β,因为右下角满足染色条件,变换后;黑色节点(3、5)、红色节点(4、6)。
  • γ,之后看被红色连线链接的节点7、4、2,最小节点在中间,左旋平衡树结构。
  • δ,左旋完成后,红色链接线的7、4、2为做倾顺序节点,因此需要做右旋操作。
  • ε,左旋、右旋,调整完成后,又满足了染色操作。到此恢复红黑树平衡。

注意,所有连接红色节点的,都是是红色线。以此与2-3树做对应。

2.3. 删除操作

根据2-3-4树模型的红黑树,在删除的时候基本是按照2-3方式进行删除,只不过在这个过程中需要染色和旋转操作,以保持树平衡。删除过程主要可以分为如图四种情况,如下;

2.3.1 删除子叶红色节点

红色子叶节点的删除并不会破坏树平衡,也不影响树高,所以直接删除即可,如下;

2.3.2 删除左侧节点

2.3.2.1 被删节点兄弟为黑色&含右子节点

2.3.2.2 被删节点兄弟为黑色&含左子节点

2.3.2.3 被删节点兄弟为黑色&含双子节点(红)

2.3.2.4 被删节点兄弟为黑色&不含子节点

2.3.2.5 被删节点兄弟为黑色&含双黑节点(黑)

2.3.3 删除右侧节点

2.3.3.1 被删节点兄弟为黑色&含左子节点

2.3.3.2 被删节点兄弟为黑色&含右子节点

2.3.3.3 被删节点兄弟为黑色&含双子节点(红)

2.3.2.4 被删节点兄弟为黑色&不含子节点

2.3.2.5 被删节点兄弟为黑色&含双黑节点(黑)

七、总结

  • HashMap的数据结构设计的非常优秀,同时也非常复杂,涉及的知识点众多。作为高级开发的程序员虽然平时开发不需要实现这样的功能,但是这里的设计思想非常值得学习。
  • 这里的知识点基本包括了;Hash值的设计,HashMap中,1、散列表实现2、扰动函数3、初始化容量4、负载因子5、扩容元素拆分得算法的制定以及2-3-4树到红黑树的转换,都非常值得深入学习。
  • 面试只是一份新工作的开发,就像比武招亲一样,你总要拿出自己最优秀的实力,如果当前能力还不足,那么就可以继续深入学习。