文章目录
HashMap
面试题
底层数据结构,1.7与1.8有何不同?
1.7利用数组+链表,1.8是数组+(链表|红黑树)
为何要用红黑树?
- 红黑树用来避免DoS攻击,防止链表超长时性能下降,树化应当是偶然情况
- hash表的查找,更新的时间复杂度是o(1),而红黑树的查找,更新的时间复杂度是O(log₂n),TreeNode占用空间也比普通Node 的大,如非必要,尽量还是使用链表。
- hash值如果足够随机,则在 hash表内按泊松分布,在负载因子0.75的情况下,长度超过8的链表出现概率是0.00000006,选择8就是为了让树化几率足够小。
- 问题1:为什么不使用二叉排序树?
问题主要出现在二叉排序树在添加元素的时候极端情况下会出现线性结构。
举例说明:由于二叉排序树左子树所有节点的值均小于根节点的特点,如果我们添加的元素都比根节点小,会导致左子树线性增长,这样就失去了用树型结构替换链表的初衷,导致查询时间增长。所以这是不用二叉查找树的原因。 - 问题2:为什么不使用平衡二叉树呢?
①红黑树不追求"完全平衡",即不像AVL那样要求节点的 |balFact| <= 1,它只要求部分达到平衡,但是提出了为节点增加颜色,红黑是用非严格的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在三次旋转之内解决,而AVL是严格平衡树,因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。
就插入节点导致树失衡的情况,AVL和RB-Tree都是最多两次树旋转来实现复衡rebalance,旋转的量级是O(1)
删除节点导致失衡,AVL需要维护从被删除节点到根节点root这条路径上所有节点的平衡,旋转的量级为O(logN),而RB-Tree最多只需要旋转3次实现复衡,只需O(1),所以说RB-Tree删除节点的rebalance的效率更高,开销更小!
AVL的结构相较于RB-Tree更为平衡,插入和删除引起失衡,如2所述,RB-Tree复衡效率更高;当然,由于AVL高度平衡,因此AVL的Search效率更高啦。
针对插入和删除节点导致失衡后的rebalance操作,红黑树能够提供一个比较"便宜"的解决方案,降低开销,是对search,insert ,以及delete效率的折衷,总体来说,RB-Tree的统计性能高于AVL.故引入RB-Tree是功能、性能、空间开销的折中结果。
② AVL更平衡,结构上更加直观,时间效能针对读取而言更高;维护稍慢,空间开销较大。
③ 红黑树,读取略逊于AVL,维护强于AVL,空间开销与AVL类似,内容极多时略优于AVL,维护优于AVL。
基本上主要的几种平衡树看来,红黑树有着良好的稳定性和完整的功能,性能表现也很不错,综合实力强,在诸如STL的场景中需要稳定表现。 - 问题3:为什么不使用b+树呢?
B+树在数据库中被应用的原因是其“矮胖”的特点,B+树的非叶子结点不存储数据,所以每个结点能存储的关键字更多。所以B+树更能应对大量数据的情况。jdk1.7中的HashMap本来是数组+链表的形式,链表由于其查找慢的特点,所以需要被查找效率更高的树结构来替换。如果用B+树的话,在数据量不是很多的情况下,数据都会“挤在”一个结点里面。这个时候遍历效率就退化成了链表。
结论:b+树不属于二叉树,因为二叉查找树的查找效率是最高的,如果内存能装下完整的树,最好使用二叉查找树,b+树是退而求其次的方式。 - 问题4:为什么不使用跳表呢?
跳跃表也可以很快的查询数据,但是 HashMap 的 各个Entry 之间并没有内在的排序关系,跳表需要元素之间存在排序关系,否则就无法跳跃查找不是吗?TreeMap 的并发实现 ConcurrentSkipListMap 就是使用的跳表。再者就是跳跃表因为要定义多级指针,是以空间换时间的数据结构,红黑树树不需要额外的空间。
为何一上来不树化?树化阈值为何是8,何时会树化?何时会退化为链表?
树化两个条件:链表长度超过树化阈值;数组容量>=64
TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。这样就解析了为什么不是一开始就将其转换为TreeNodes,而是需要一定节点数才转为TreeNodes,说白了就是trade-off,空间和时间的权衡。
list size k are (exp(-0.5)*pow(0.5, k)/factorial(k)).
The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以,之所以选择8,不是拍拍屁股决定的,而是根据概率统计决定的。
红黑树的平均查找长度是log₂N,如果长度为8,平均查找长度为log₂8=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果是小于等于6,6/2=3,而log₂6=2.6,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
- 退化情况1:在扩容时如果拆分树时,树元素个数<=6则会退化链表
- 退化情况2:remove树节点时(移除之前检查),若root、root.left、root.right、root.left.left有一个为null,也会退化为链表,否则尽管元素个数<=6都不会链表化。
索引如何计算?hashCode都有了,为何还要提供hash()方法?
若key为空,则存入第一个bin(返回下标为0),反之计算对象的hashCode() h,并与之右移16位后去异或。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
二次hash()是为了综合高位数据,让哈希分布更均匀。
为什么HashMap的长度是2的幂
计算索引时:它通过hash & (table.length -1)来得到该对象的保存位。当 length 总是2的n次方时,hash & (length-1)运算等价于对 length 取模,也就是 hash%length,但是&比%具有更高的效率。比如 n % 32 = n & (32 -1)。
扩容时:hash & oldCap == 0的元素留在原来位置,否则新位置 = 旧位置 + oldCap
介绍一下put方法流程,1.7与1.8有何不同?
- HashMap是懒创建数组的,第一次put才创建数组
- 计算索引(桶下标)
- 如果桶下标没有值,创建Node占位返回
- 如果桶下标有值
- 已经是TreeNode走红黑树的添加或更新
- 普通的Node,走链表的添加或更新,如果链表长度超过阈值8,树化。
- 返回前检查容量是否超过阈值,一旦超过则进行扩容
1.8:
不同:
- 链表插入节点时,1.7是头插法,1.8是尾插法
- 1.7是大于等于阈值时且没有空位(当前元素个数大于等于阈值时,且当下一个元素插入时对应的桶有元素了)时才扩容,而1.8是大于阈值就扩容
- 1.8在扩容计算Node索引时,会优化,采用hash & oldCap == 0的元素留在原来位置,否则新位置 = 旧位置 + oldCap
加载因子为何默认是0.75f
- 在空间占有与查询时间之间取得较好的权衡
- 大于这个值,空间节省了,但链表就会比较影响性能
- 小于这个值,冲突减少了,但扩容就会更频繁,占用空间多
多线程下会有什么问题?
-
扩容死链(1.7)
-
数据错乱(1.7,1.8)
key能否为null,作为key的对象有什么要求
- HashMap的key可以为null,但Map的其他实现要求不然
- 作为key的对象,必须实现hashCode和equals,并且key的内容不能修改(不可变)
String对象的hashCode()如何设计的,为啥每次乘的是31
目标是达到较为均匀度散列效果,每一个字符串的hashCode足够独特
-
字符串中的每一个字符都可以表现为一个数字,称为Si,其中i的范围是0~n-1
-
散列公式为:
S 0 ∗ 3 1 n − 1 + S 1 ∗ 3 1 n − 2 + . . . S i ∗ 3 1 n − 1 − i + . . . S n − 1 ∗ 3 1 0 S_0*31^{n-1}+S_1*31^{n-2}+...S_i*31^{n-1-i}+...S_{n-1}*31^0 S0∗31n−1+S1∗31n−2+...Si∗31n−1−i+...Sn−1∗310 -
31代入公式有较好的散列特性,并且31*h可以被优化为
- 32 ∗ h − h = = > 2 5 ∗ h − h = = > h < < 5 − h 32*h-h ==> 2^5*h-h==>h <<5-h 32∗h−h==>25∗h−h==>h<<5−h
1.8 构造方法
1、HashMap():无参构造方法,设置扩容阈值为0.75f
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted 0.75f
}
2、HashMap(int initialCapacity):构造一个具有指定初始容量和默认负载因子(0.75)的空HashMap
// static final float DEFAULT_LOAD_FACTOR = 0.75f
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
3、HashMap(int initialCapacity, float loadFactor):构造一个具有指定初始容量和负载因子的空HashMap。
// static final int MAXIMUM_CAPACITY = 1 << 30 = 1073741824
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
/**
* 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;
}
1.8 扩容机制
-
第一次扩容是在存入第一个元素时,容量为16
/** * The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
-
当表中的元素个数等于 原表长度*扩容阈值时,再次存入一个元素即触发扩容,扩容为原表长度的两倍,并将表中元素重新取hash值存入相应的位置。
// size:The number of key-value mappings contained in this map. // threshold:The next size value at which to resize (capacity * load factor). if (++size > threshold) resize();
-
当链表长度大于8 且 表长度小于64时 采用扩容的方式来减小链表长度
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); treeifyBin: if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // MIN_TREEIFY_CAPACITY 64 resize();
-
当链表长度大于8 且 表长度大于等于64时,插入一个元素使得链表长度大于8时,即树化。