从二叉树和散列表到HashMap

二叉树

二叉树的特点

1、每个节点最多两个子节点,分别是左子节点和右子节点。

2、允许有的节点只有左子节点,有的节点只有右子节点。

3、二叉树每个节点的左子树和右子树也分别满足二叉树的定义。

Java中二叉树的实现

1、链式存储(常用)

public class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;
    TreeNode() {}
    TreeNode(int val) { this.val = val; }
    TreeNode(int val, TreeNode left, TreeNode right) {
        this.val = val;
        this.left = left;
        this.right = right;
    }
}

2、数组存储

数组实现的二叉树通常是完全二叉树,以避免空间浪费。

  1. 确定数组大小: 首先确定数组的大小,通常可以根据二叉树的高度来确定数组的大小。如果二叉树的高度为 h,那么数组的大小应该为 2^h - 1,这样可以保证数组能够存储所有可能的节点。

  2. 节点存储顺序: 将二叉树的节点按照层序遍历的顺序依次存储到数组中。通常从根节点开始,依次存储根节点的左子节点、右子节点,然后继续存储左子节点的左右子节点、右子节点的左右子节点,以此类推。

  3. 节点索引关系: 假设数组索引从 0 开始,那么对于节点 i,其左子节点的索引为 2*i + 1,右子节点的索引为 2*i + 2,父节点的索引为 (i - 1) / 2。

二叉树的分类

满二叉树、完全二叉树、二叉搜索树、红黑树 ,重点关注后两者。

二叉搜索树

二叉搜索树(Binary Search Tree,BST),又名二叉查找树,有序二叉树或者排序二叉树,满足:

1、任意一个节点的左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

2、左子树和右子树都是一棵二叉搜索树。

3、没有键值相等的节点。

时间复杂度:插入,查找,删除的时间复杂度O(logn),插入或删除的前提都是先找到。

最坏退化为链表,时间复杂度变为O(n)。

 红黑树

一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树。

5条红黑规则——保证红黑树平衡

性质1:节点要么是红色,要么是黑色

性质2:根节点是黑色

性质3:叶子节点都是黑色的空节点

性质4:红色节点的子节点都是黑色

性质5:从任一节点到叶子节点的所有路径都包含相同数目的黑色节点

在添加或删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质。

红黑树查找、添加、删除的时间复杂度都是O(logn)。

散列表

散列表(Hash Table),又名哈希表/Hash表,是根据键(Key)直接访问在内存存储的位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性。

散列函数:将键(key)映射为数组下标的函数,可以表示为:hashValue = hash(key)

散列函数的基本要求:equals相等时,hashcode值也必须相等

  • 散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标。

  • 如果key1 == key2,那么经过hash后得到的哈希值也必相同,即:hash(key1) == hash(key2)

  • 如果key1 != key2,那么经过hash后得到的哈希值也必不相同,即:hash(key1) != hash(key2)

“如果key1 != key2,那么经过hash后得到的哈希值也必不相同”,很难实现,容易导致哈希冲突/散列冲突/哈希碰撞,即多个key映射到同一个数组下标位置

拉链法(链表法)

在散列表中,数组的每个下标位置可以称为(bucket)或者(slot),每个桶(槽)会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中

  • 插入操作:通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,插入的时间复杂度是 O(1)

  • 当查找、删除一个元素时:同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除

    • 平均情况下,基于链表法解决冲突时查询的时间复杂度是O(1)

    • 最差情况下,散列表可能会退化为链表,查询的时间复杂度就从 O(1) 退化为 O(n)

    • 将链表法中的链表改造为其他高效的动态数据结构,比如红黑树,查询的时间复杂度是 O(logn)

将链表改成红黑树,可以提高效率,且防止DDos攻击

DDos 攻击:Distributed Denial of Service,分布式拒绝服务攻击。

指处于不同位置的多个攻击者同时向一个或数个目标发动攻击,或者一个攻击者控制了位于不同位置的多台机器并利用这些机器对受害者同时实施攻击。由于攻击的发出点是分布在不同地方的,这类攻击称为分布式拒绝服务攻击,其中的攻击者可以有多个。

通过使用红黑树,可以防止攻击者通过发送大量具有相同哈希值的请求来使得链表变得极端不平衡,从而导致查询效率急剧下降。

HashMap

HashMap的实现原理?

1、数据结构: 底层使用hash表,即数组和链表或红黑树链表的长度大于8数组长度大于64转换为红黑树

2、put元素

  • 利用key的hashCode重新hash计算出当前对象的元素在数组中的下标

  • 存储时,如果出现hash值相同的key,此时有两种情况:

    • 如果key相同,则覆盖原始值;

    • 如果key不同(出现冲突),则将当前的key-value放入链表或红黑树

3、 获取元素时,直接找到hash值对应的下标,再进一步判断key是否相同,从而找到对应值。

根据哈希值找到对应的下标后,根据key在链表或者红黑树找到对应的value。

每个桶(槽)下挂的链表/红黑树中的元素的key都是不同的。

HashMap的jdk1.7和jdk1.8有什么区别?

解决哈希冲突的数据结构和具体过程有区别。

JDK1.8之前:将链表和数组相结合,创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

JDK1.8:在解决哈希冲突时有了较大的变化,采用数组+链表+红黑树

  • 链表长度大于8且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。

  • 扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表。

  put方法的具体流程?

DEFAULT_INITIAL_CAPACITY 默认的初始容量16

DEFAULT_LOAD_FACTOR 默认的加载因子0.75

扩容阈值 == 数组容量 * 加载因子

HashMap是懒加载,在创建对象时并没有初始化数组。

在无参构造函数中,设置了默认的加载因子是0.75

 

  1. 判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)

  2. 根据键值key计算hash值得到数组索引

  3. 判断table[i] == null,条件成立,直接新建节点添加

  4. 如果table[i] != null

    4.1 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value

    4.2 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对

    4.3 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value

  5. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。

HashMap的扩容机制?

  • 在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)

  • 每次扩容的时候,都是扩容之前容量的2倍;

  • 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中:

    • 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置

    • 如果是红黑树,走红黑树的添加

    • 如果是链表,则需要遍历链表,可能需要拆分链表判断(e.hash & oldCap)是否为0,为0就停留在原始位置,不为0就移动到原始位置+增加的数组大小这个位置上。

e.hash是节点的哈希值。

newCap 是 2 的幂次方时,(newCap - 1) 的二进制表示只包含 1,没有 0,而且它比 newCap 小 1,所以按位与操作结果范围是 [0, newCap - 1]。

e.hash & oldCap 这个表达式用于判断节点 e 的哈希值是否在扩容前的数组容量 oldCap 内。当结果为 0 时,表示节点 e 的哈希值在扩容前的数组容量内,即哈希值的高位(在二进制表示中)在扩容前的数组长度 oldCap 之内。

位与&:仅当两个操作数的对应比特位都为 1 时,结果的对应比特位才为 1,否则为 0。可以理解为一种截断操作,截断了哈希值的高位部分,只保留了低位部分。

HashMap的寻址算法?

1、计算对象的hashCode()

2、二次哈希:哈希值右移16位再异或哈希值,能让hash值更加均匀,减少hash冲突。

3、计算数组索引:数组长度减1位与哈希,等价于哈希模与数组长度,得到数组索引。

// 扰动算法,是hash值更加均匀,减少hash冲突
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// (n-1) & hash : 得到数组中的索引,等价于hash % n,性能更好
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
……
    if ((p = tab[i = (n - 1) & hash]) == null)
……
}

为何HashMap的数组长度一定是2的次幂?

2的次幂可以使用位与,位与效率更高。

1、计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模

2、 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

JDK1.7的多线程死循环问题

数组+链表,数组扩容时,因为链表是头插法,在进行数据迁移时,有可能导致死循环。

比如说,现在有两个线程:线程1和线程2。

线程1:读取到当前的HashMap数据,数据中一个链表,在准备扩容时,线程2介入

线程2:也读取HashMap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,即B->A,线程2执行结束。

线程1:继续执行的时候就会出现死循环的问题。线程1先将A移入新的链表,再将B插入到链头,即A->B,所以B->A->B,形成死循环。

JDK 8 将扩容算法做了调整,改用尾插法(保持与扩容前一样的顺序),避免了JDK7中死循环的问题。

HashSet与HashMap的区别

HashSet 是基于 HashMap 实现的,但是它的元素存储在 HashMap 的键的位置上,而值都是一个固定的对象。

区别在于:实现的接口、存储内容、添加元素的方法名、哈希计算的对象

 

HashTable与HashMap的区别

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值