HashMap 还是非常有意思的,远不止一个扰动函数的泊松分布,还有负载因子、拉链寻址、数据迁移等等。正好最近研究数据库路由数据散列,折腾过这部分源码,来回答一波!
// 获取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作为乘积值呢?
在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.
这段内容主要阐述的观点包括;
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.
接下来要做的事情并不难,只是根据stackoverflow
的回答,统计出不同的乘积数对10万个单词的hash计算结果。10个单词表已提供,可以通过关注公众号:bugstack虫洞栈进行下载
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.[生]丘泽,高低位镶嵌沼泽"
资源下载
进行获取 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; }
想计算碰撞很简单,也就是计算那些出现相同哈希值的数量,计算出碰撞总量即可。这里的实现方式有很多,可以使用set
、map
也可以使用java8
的stream
流统计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); }
@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)); } }
2, 3, 5, 7, 17, 31, 32, 33, 39, 41, 199
,最终返回一个list结果并输出。测试结果
单词数量: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碰撞结果图标展示,从这里可以看出如下信息;
除了以上看到哈希值在不同乘数的一个碰撞概率后,关于散列表也就是hash,还有一个非常重要的点,那就是要尽可能的让数据散列分布。只有这样才能减少hash碰撞次数,也就是后面章节要讲到的hashMap源码。
那么怎么看散列分布呢?如果我们能把10万个hash值铺到图表上,形成的一张图,就可以看出整个散列分布。但是这样的图会比较大,当我们缩小看后,就成一个了大黑点。所以这里我们采取分段统计,把2 ^ 32方分64个格子进行存放,每个格子都会有对应的数量的hash值,最终把这些数据展示在图表上。
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
取值范围内,每个哈希值存放到不同格子里的数量。 @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()); }
统计图表
学习HashMap前,最好的方式是先了解这是一种怎么样的数据结构来存放数据。而HashMap经过多个版本的迭代后,乍一看代码还是很复杂的。就像你原来只穿个裤衩,现在还有秋裤和风衣。所以我们先来看看最根本的HashMap是什么样,也就是只穿裤衩是什么效果,之后再去分析它的源码。
**问题:**假设我们有一组7个字符串,需要存放到数组中,但要求在获取每个元素的时候时间复杂度是O(1)。也就是说你不能通过循环遍历的方式进行获取,而是要定位到数组ID直接获取相应的元素。
**方案:**如果说我们需要通过ID从数组中获取元素,那么就需要把每个字符串都计算出一个在数组中的位置ID。字符串获取ID你能想到什么方式? 一个字符串最直接的获取跟数字相关的信息就是HashCode,可HashCode的取值范围太大了[-2147483648, 2147483647]
,不可能直接使用。那么就需要使用HashCode与数组长度做与运算,得到一个可以在数组中出现的位置。如果说有两个元素得到同样的ID,那么这个数组ID下就存放两个字符串。
以上呢其实就是我们要把字符串散列到数组中的一个基本思路,接下来我们就把这个思路用代码实现出来。
// 初始化一组字符串 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));
这段代码整体看起来也是非常简单,并没有什么复杂度,主要包括以下内容;
0111
除高位以外都是1的特征,也是为了散列。key.hashCode() & (tab.length - 1)
。模拟链表的过程
。测试结果
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,"小傅哥"]
e4we->plop
。如果上面的测试结果不能在你的头脑中很好的建立出一个数据结构,那么可以看以下这张散列示意图,方便理解;
以上我们实现了一个简单的HashMap,或者说还算不上HashMap,只能算做一个散列数据存放的雏形。但这样的一个数据结构放在实际使用中,会有哪些问题呢?
以上这些问题可以归纳为;扰动函数
、初始化容量
、负载因子
、扩容方法
以及链表和红黑树
转换的使用等。接下来我们会逐个问题进行分析。
在HashMap存放元素时候有这样一段代码来处理哈希值,这是java 8
的散列值扰动函数,用于优化散列效果;
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
理论上来说字符串的hashCode
是一个int类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个hashCode
的取值范围是[-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。
我们默认初始化的Map大小是16个长度 DEFAULT_INITIAL_CAPACITY = 1 << 4
,所以获取的Hash值并不能直接作为下标使用,需要与数组长度进行取模运算得到一个下标值,也就是我们上面做的散列列子。
那么,hashMap源码这里不只是直接获取哈希值,还进行了一次扰动计算,(h = key.hashCode()) ^ (h >>> 16)
。把哈希值右移16位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性。计算方式如下图;
从上面的分析可以看出,扰动函数使用了哈希值的高半区和低半区做异或,混合原始哈希码的高位和低位,以此来加大低位区的随机性。
但看不到实验数据的话,这终究是一段理论,具体这段哈希值真的被增加了随机性没有,并不知道。所以这里我们要做一个实验,这个实验是这样做;
扰动函数对比方法
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中生成图表。
以上的两张图,分别是没有使用扰动函数和使用扰动函数的,下标分配。实验数据;
未使用扰动函数
使用扰动函数
接下来我们讨论下一个问题,从我们模仿HashMap的例子中以及HashMap默认的初始化大小里,都可以知道,散列数组需要一个2的倍数的长度,因为只有2的倍数在减1的时候,才会出现01111
这样的值。
那么这里就有一个问题,我们在初始化HashMap的时候,如果传一个17个的值new HashMap<>(17);
,它会怎么处理呢?
在HashMap的初始化中,有这样一段方法;
public HashMap(int initialCapacity, float loadFactor) { ... this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }
threshold
,通过方法tableSizeFor
进行计算,是根据初始化来计算的。计算阀值大小的方法;
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; }
那这里我们把17这样一个初始化计算阀值的过程,用图展示出来,方便理解;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
负载因子是做什么的?
负载因子,可以理解成一辆车可承重重量超过某个阀值时,把货放到新的车上。
那么在HashMap中,负载因子决定了数据量多少了以后进行扩容。这里要提到上面做的HashMap例子,我们准备了7个元素,但是最后还有3个位置空余,2个位置存放了2个元素。 所以可能即使你数据比数组容量大时也是不一定能正正好好的把数组占满的,而是在某些小标位置出现了大量的碰撞,只能在同一个位置用链表存放,那么这样就失去了Map数组的性能。
所以,要选择一个合理的大小下进行扩容,默认值0.75就是说当阀值容量占了3/4s时赶紧扩容,减少Hash碰撞。
同时0.75是一个默认构造值,在创建HashMap也可以调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。
为什么扩容,因为数组长度不足了。那扩容最直接的问题,就是需要把元素拆分到新的数组中。拆分元素的过程中,原jdk1.7中会需要重新计算哈希值,但是到jdk1.8中已经进行优化,不在需要重新计算,提升了拆分的性能,设计的还是非常巧妙的。
@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 + " Idx(16):" + ((16 - 1) & hash) + " Bit值:" + Integer.toBinaryString(hash) + " - " + Integer.toBinaryString(hash & 16) + " Idx(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
zuio
因计算结果 hash & oldCap
不为1,则被迁移到下标位置24。通过上一章节的学习:《HashMap核心知识,扰动函数、负载因子、扩容链表拆分,深度学习》
大家对于一个散列表数据结构的HashMap往里面插入数据时,基本已经有了一个印象。简单来说就是通过你的Key值取得哈希再计算下标,之后把相应的数据存放到里面。
但再这个过程中会遇到一些问题,比如;
这些疑问点都会在后面的内容中逐步讲解,也可以自己思考一下,如果是你来设计,你会怎么做。
HashMap插入数据流程图
visio原版流程图,可以通过关注公众号:bugstack虫洞栈,进行下载
以上就是HashMap中一个数据插入的整体流程,包括了;计算下标、何时扩容、何时链表转红黑树等,具体如下;
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
tab[i = (n - 1) & hash])
treeifyBin(tab, hash);
threshold
,超过则扩容。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; }
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; }
以上的代码稍微有些长,但是整体的逻辑还是蛮清晰的,主要包括;
new Node[newCap];
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); } }
这一部分链表树化的操作并不复杂,复杂点在于下一层的红黑树转换上,这部分知识点会在后续章节中专门介绍;
以上源码主要包括的知识点如下;
tl.next = p
,这主要方便后续树转链表和拆分更方便。tieBreakOrder
加时赛,这主要是因为HashMap没有像TreeMap那样本身就有Comparator的实现。在链表转红黑树中我们重点介绍了一句,在转换树的过程中,记录了原有链表的顺序。
那么,这就简单了,红黑树转链表时候,直接把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); }
因为记录了链表关系,所以替换过程很容易。所以好的数据结构可以让操作变得更加容易。
上图就是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; }
以上查找的代码还是比较简单的,主要包括以下知识点;
tab[(n - 1) & hash])
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; }
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()
,它们遍历的结果是固定的。
那么从实现的角度来看,这些种遍历都是从散列表中的链表和红黑树获取集合值,那么他们有一个什么固定的规律吗?
测试的场景和前提;
代码测试
@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("
排序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("
排序03:"); for (String key : map.keySet()) { System.out.print(key + " "); } }
这段代码分别测试了三种场景,如下;
排序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()测试结果可以看到,如下信息;
moveRootToFront()方法
日常的学习和一部分伙伴的面试中,竟然会听 到的是;从HashMap中文红黑树、从数据库索引为B+Tree,但问2-3树的情况就不是很多了。
从最根本的原因来看,使用树结构就是为了提升整体的效率;插入、删除、查找(索引),尤其是索引操作。因为相比于链表,一个平衡树的索引时间复杂度是O(logn),而数组的索引时间复杂度是O(n)。
从以下的图上可以对比,两者的索引耗时情况;
在树的数据结构中,最先有点是二叉查找树,也就是英文缩写BST树。在使用数据插入的过程中,理想情况下它是一个平衡的二叉树,但实际上可能会出现二叉树都一边倒,让二叉树像列表一样的数据结构。从而树形结构的时间复杂度也从O(logn)
升级到O(n)
,如下图;
综上呢,如果我们希望在插入数据后又保持树的特点,O(logn)的索引性能,那么就需要在插入时进行节点的调整
2-3树是什么结构,它怎么解决平衡问题的。带着问题我们继续 。
2-3树是一种非常巧妙的结构,在保持树结构的基础上,它允许在一个节点中可以有两个元素,等元素数量等于3个时候再进行调整。通过这种方式呢,来保证整个二叉搜索树的平衡性。
这样说可能还没有感觉,来看下图;
2-3树已经可以解决平衡问题那么,数据是怎么存放和调整的呢,接下来我们开始实践使用。
2-3树,读法;二三树,特性如下;
序号 | 描述 | 示意图 |
---|
综上我们可以总结出,2-3树的一些性质;
接下来我们就模拟在二叉搜索树中退化成链表的数据,插入到2-3树的变化过程,数据包括;1、2、3、4、5、6、7
,插入过程图如下;
以上,就是整个数据在插入过程中,2-3树的演化过程,接下来我们具体讲解每一步的变化;
3、4、5
共用1个节点,当一个节点上有三个数据时候,则需要进行调整。5 6
共用。此时是一个临时存放,需要调整。初步调整后,抽出6节点,向上存放,变为2 4 6
共用一个节点,这是一个临时状态,还需要继续调整。2、4、6
,则继续需要把中间节点上移,1、3
和5、7
则分别成二叉落到节点2
、节点6
上。希腊字母:α(阿尔法)、 β(贝塔)、γ(伽马)、δ(德尔塔)、ε(伊普西隆)、ζ(截塔)、η(艾塔)、θ(西塔)、ι(约塔)
有了上面数据插入的学习,在看数据删除其实就是一个逆向的过程,在删除的主要包括这样两种情况;
承接上面 的例子,我们把数据再从7、6、5、4、3、2、1
顺序删除,观察2-3树的结构变化,如下;
5、6
合并,但此时破坏了2-3树的平衡性,需要缩短树高进行调整。2、4
合并,节点1、3
分别插入左侧和中间。再看一个稍微复杂点2-3树删除:
上面 这张图,就一个稍微复杂点的2-3平衡树,树的删除过程主要包括;
3、5
合并,指向节点2,保持树平衡。8、9
合并。15
上移,恢复成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 均是基于红黑树结构实现的。
死记硬背,很难学会
红黑树的结构和设计都非常优秀,也同样在实现上有着复杂的处理逻辑,包括插入或者删除节点时;颜色变化、旋转操作等操作。但如果只把这些知识点硬背下来,什么时候染色、什么时候旋转,是没有多大意义的,用不了多久也就忘记了。所以这部分的学习,了解其根本更重要。
在上一章节《讲解2-3平衡树「红黑树的前身」》,使用了大量图例讲解了2-3树,并在标题处写出它是红黑树的前身。阅读后更容易理解红黑树相关知识。
红黑树规则
1. 根节点是黑色 2. 节点是红黑或者黑色 3. 所有子叶节点都是黑色(叶子是NIL节点,默认没有画出来) 4. 每个红色节点必须有两个黑色子节点(也同样说明一条链路上不能有链路的红色节点) 5. 黑高,从任一节点到齐每个叶子节点,经过的路径都包含相同数目的黑色节点
那么,这些规则是怎么总结定义出来的呢?接下里我们一步步分析讲解。
首先2-3树
(读法:二三树)就是一个节点有1个或者2个元素,而实际上2-3树转红黑树是由概念模型2-3-4树
转换而来的。-4叉
就是一个节点里有3个元素,这在2-3树中会被调整,但是在概念模型中是会被保留的。
虽然2-3-4树
也是具备2-3树
同样的平衡树的特性,但是如果直接把这样的模型用代码实现就会很麻烦,且效率不高,这里的复杂点包括;
所以,希望找到一种平衡关系,既保持2-3树平衡和O(logn)的特性,又能在代码实现上更加方便,那么就诞生了红黑树。
2-3树
转红黑树,也可以说红黑树是2-3树
和2-3-4树
的另外一种表现形式,也就是更利于编码实现的形式。
简单转换示例;
从上图可以看出,2-3-4树与红黑树的转换关系,包括;
综上,就是2-3-4树的节点转换,总结出来的规则,如下;
在简单2-3树转换红黑树
的过程中,了解到一个基本的转换规则右旋定义,接下来我们在一个稍微复杂一点的2-3树
与红黑树的对应关系,如下图;
上图是一个稍微复杂点的2-3树,转换为红黑树的过程,是不这样一张图让你对红黑树更有感觉了,同时它也满足一下条件;
通过在上一章节2-3树的学习,在插入节点时并不会插到空位置,而是与现有节点融合以及调整,保持整个树的平衡。
而红黑树是2-3-4树的一种概念模型转换而来,在插入节点时通过红色链接相连,也就是插入红色节点。插入完成后进行调整,以保持树接近平衡。
那么,为了让红黑树达到平衡状态,主要包括染色、↔左右旋转、这些做法其实都是从2-3树演化过来的。接下来我们就分别讲解几种规则的演化过程,以此更好了解红黑树的平衡操作。
左旋定义: 把一个向右倾斜的红节点链接(2-3树,3-叉双元素节点),转化为左链接。
背景:顺序插入元素,1、2、3,2-3树保持平衡,红黑树暂时处于右倾斜。
接下来我们分别对比两种树结构的平衡操作;
红黑树的左旋,只会处理与之对应的2-3树节点进行操作,不会整体改变。
右旋定义: 把一个向左倾斜的红节点连接(2-3树,3-叉双元素节点),转换为右连接。
背景:顺序插入元素,3、1、1,2-3树保持平衡,红黑树暂时处于左倾斜。
接下来我们分别对比两种树结构的平衡操作;
你会发现,左旋与右旋是相互对应的,但在2-3树中是保持不变的
左旋、右旋,我们已经有了一个基本的概念,那么接下来我们再看一个可以综合左右旋以及对应2-3树的演化案例,如下;
以上的例子分别演示了一个元素插入的三种情况,如下;
在2-3树中,插入一个节点,为了保持树平衡是不插入到空位置上的,当插入节点后元素数量有3个后则需要调整中间元素向上,来保持树平衡。与之对应的红黑树则需要调整颜色,来保证红黑树的平衡规则,具体参考如下;
接下来我们把上面讲解到的旋转
、染色
,运用到一个实际案例中,如下图;
7、2、8、1、4、3、5
3、5、6
。7、4、2
,最小节点在中间,左旋平衡树结构。7、4、2
为做倾顺序节点,因此需要做右旋操作。注意,所有连接红色节点的,都是是红色线。以此与2-3树做对应。
根据2-3-4树模型的红黑树,在删除的时候基本是按照2-3方式进行删除,只不过在这个过程中需要染色和旋转操作,以保持树平衡。删除过程主要可以分为如图四种情况,如下;
红色子叶节点的删除并不会破坏树平衡,也不影响树高,所以直接删除即可,如下;
1、散列表实现
、2、扰动函数
、3、初始化容量
、4、负载因子
、5、扩容元素拆分
得算法的制定以及2-3-4树到红黑树的转换,都非常值得深入学习。