【集合】Map 和 Set

目录

一、Hash

1.1 什么是 hash

1.2 哈希冲突(碰撞)

二、Map 集合类

2.1 HashMap

2.1.1 概念

2.1.2 特点总结

2.1.3 存储过程

2.2 常见问题

2.2.1 计算节点索引值的几种算法

2.2.2 当出现哈希碰撞会怎么样

2.2.3 扩容

2.2.4 Hash 的继承关系

2.2.5 默认容量是多少。为什么必须是2的n次幂?

2.2.6 加载因子的作用。默认加载因子是多少?

2.2.7 为什么 HashMap 桶中的节点个数超过 8 才转为红黑树?

2.2.8 存储结构-字段

2.2.9 遍历 HashMap 的四种方式

2.2.10 HashMap 在 jdk1.7 和 jdk1.8 中的对比

2.3 HashMap添加的对象为什么要重写 equals 和 hashcode

2.3.1 equals 和  == 的区别

2.3.2 为什么要重写 hashCode 和 equals

2.4 HashTable 和 HashMap 的区别

2.5 LinkedHashMap

2.6 TreeMap

2.7 ConcurrentHashMap

三、Set 类

3.1 概念

3.2 HashSet

3.3 TreeSet

3.4 LinkedHashSet


一、Hash

1.1 什么是 hash

哈希是将任意长度的输入通过散列算法,变换成固定长度的输出,这个输出就是哈希值。

● 空间压缩

这种转换实际是一种空间映射,哈希值的空间通常小于原输入占用的空间

● 没有唯一性

不同的输入可能转成出同一个哈希值,但不同的哈希值必定对应着不同的输入

 

1.2 哈希冲突(碰撞)

哈希冲突即出现输入不同,转换出的哈希值却相同的情况

 

二、Map 集合类

2.1 HashMap

2.1.1 概念

HashMap 是基于哈希表的 Map 接口的非同步实现,提供键值对的映射操作,允许保存 null,不保证映射的顺序。

在 JDK 1.8 之前是由 链表+数组 组成的,主体是数组,链表这是为了处理哈希冲突

在 JDK 1.8 后,引入了红黑树的方式解决哈希冲突,当链表的长度大于阈值(默认为8)且当前的数组长度大于 64 时,就会将这个节点上的所有数据改为用红黑树存储。若链表长度大于8但数组长度小于64时,则依旧使用链表,且数组扩容

 

● 为什么要数组长度不小于64,节点存储才转变成红黑树

当数组长度较小时,红黑树的深度也会增加,反而会降低效率。由于红黑树需要进行左旋、右旋、变色这些操作来保持平衡,深度越深,重排的耗费也就越高。

 

2.1.2 特点总结

  • 存取是无序的
  • 键和值都可以是null,但键值要求唯一,即只能存一个null
  • 键值唯一
  • jdk1.8 前的数据结构是:数组+链表,1.8后变为:数组+链表+红黑树
  • HashMap 中可能同时存在链表和红黑树,只有当某个数组节点的数据节点大于8,且整个数组的长度大于64时,这个节点的链表才会转换成红黑树

 

2.1.3 存储过程

● 拉链法

  • 先根据 Key 值,使用 hashCode() 方法计算对应的 hash 值,之后结合数组长度,采用算法计算出在数组中存储数据的节点索引值。
  • 若计算后的这个数组节点中没有数据,则直接将原始键值对数据存储到节点中。
  • 若有数据,则会使用 equeals() 方法逐个比较原数据的 key 的 hash 值,和节点中已存在的 key 的 hash 值是否相等,相等则将 Value 覆盖,不相等则将数据添加到链表后端。

size: 表示 HashMap 中 K-V 的实时数量

threshold (临界值) =  capacity (容量) * loanFactor (加载因子),这个值是当前已占用数组长度的最大值,当 size 超过这个临界值就会进行扩容,扩容后的 HashMap 容量是之前容量的两倍

 

2.2 常见问题

2.2.1 计算节点索引值的几种算法

● 底层默认

使用对 key 的 hashCode值结合数组长度进行无符号右移(>>>)、按位异或(^),按位与(&)计算出索引

● 其他方法

平方取中法,取余数,伪随机法等,但默认的位运算方式效率更高

 

2.2.2 当出现哈希碰撞会怎么样

当两个元素的key的hash值相等时,会产生哈希碰撞,若 key 值相同,则新的 value 值会覆盖旧的 value,若 key 值不同,则会添加到链表的后面,若链表长度超过阈值8且数组长度查过64,则会转为红黑树存储

 

2.2.3 扩容

当超出临界值时,会进行扩容,默认扩容为原容量的两倍,创建一个容量是当前容量一倍的数据,并将原来的数组数据复制过来

扩容后的节点要么在原位置,要么会被分配到 原位置+旧容量 的位置,由此在扩充 HashMap 时,不需要重新计算 hash,只需要看原来的 hash 新增的那个 bit 是 0 还是 1,若是 0 则在原位置,若是 1 则在 原位置+旧容器 的位置,如:

 

2.2.4 Hash 的继承关系

继承关系如下:

Cloneable: 表示可以进行克隆,创建并返回一个 HashMap 对象的副本

Serializable: 序列化接口,表示 HashMap 可以被序列化和反序列化

AbstractMap: 父类提供了 Map 实现接口,以最大限度地减少实现此接口所需的工作

 

2.2.5 默认容量是多少。为什么必须是2的n次幂?

● 默认容量

默认容量为 16(1<<4),可以在初始化时指定容量。

集合的最大容量为 1<< 30,即 2 的 30 次幂

为什么必须是 2 的 n 次幂

HashMap 为了存取高效,尽量减少碰撞,将数据均匀分配到数组中,故一般使用取模的方式进行分配索引。

由于直接取模效率不如位运算,故源码做了优化,使用  hash&(length-1),效果等同于 hash%length,而等价的前提就是 length 是 2 的 n 次幂。

若数组长度不是 2 的 n 次幂会发生什么

计算出的索引十分容易发生先沟通,容易发生哈希碰撞导致数组其他空间空闲

● 若数组初始化时传入的值不是 2 的 n 次幂会怎样

HashMap 允许在初始化时指定 initialCapacity 来指定数组容量

HashMap 会自动找到大于等于 initialCapacity 的最小的 2 的幂(如传入是10, 则会找到12)

源码分析

1) 先对 cap 减一,防止一直输入的是 2 的 n 次幂了。如输入的 cap 为16, 已经是 2 的 n 次幂了,若直接使用 16 则移位后会得到 32.故先减一变为15,移位得到16

2) 通过不断的移位和或操作,使得最高位1之后全部变成1

如 00000000 00000000 00000000 00001001 移位后得到   00000000 00000000 00000000 00001111

3) 最后得到的是一个奇数,故最后再加一

 

2.2.6 加载因子的作用。默认加载因子是多少?

● 为什么要有加载因子

HashMap 扩容并不是数组满了才扩容的,而是存在界限值,界限值 = 数组长度 * 加载因子。

loadFactor(加载因子) 是用来衡量 HashMap 疏密程度的,影响 hash 操作到同一个数组位置的概率。

 

加载因子设置不当会导致什么

loadFactor 太大:会导致数组填充过满,链表中的节点数量增加,也会导致生成更多的红黑树,降低元素的查找效率

loadFactor 太小:由于临界值 = 数组长度 x 影响因子,影响因子过小会导致数组容易扩容,

会导致数组的利用率低,存放的数据分散,

故官方给出了 0.75 这一个较为合适的临界值。

 

● 默认加载因子

默认的加载因子为 0.75,也可以在 HashMap 初始化时自定义

 

2.2.7 为什么 HashMap 桶中的节点个数超过 8 才转为红黑树?

https://mp.weixin.qq.com/s/QgkBRoADcO8Wgj0dHCc9dw?

HashMap 在桶中节点个数达到8,会进行树化,当节点小于等于6个时,会转为链化。这个 8 和 6 是通过衡量时间和空间后得出的。

原因总结:

● 节省内存空间

TreeNodes 占用的空间是普通 Nodes 的两倍

● 触发概率计算

从下图中可以看到,根据泊松分布计算,当临界值是 6 的时候的触发概率,比临界值是 8 的时候的概率大了 200 多倍

效率权衡

类型平均查找长度6个节点情况8个节点情况
红黑树log(n)log(6) = 2.6log(8) = 3
链表n/26/2 = 38/2 = 4

根据计算结果,若是将阈值设为6,由于2.6 和 3 相差不大,树结构的转换和生成也需要额外的时间开销,以及考虑到树节点的占用空间更大,故使用 8 作为阈值

 

2.2.8 存储结构-字段

HashMap 的实现数据结构是以 数组+链表+红黑树的方式实现的,但其具体底层是一个 Node(jdk1.8之前叫 Entry) 节点的数组,即哈希桶数组,其中的 Node 节点是负责存储键值对数据

 

2.2.9 遍历 HashMap 的四种方式

keys() 获取所有 key,values() 获取所有 value

HashMap map = new HashMap();
map.put(1,1);
map.put(2,2);
map.put(3,3);
map.put(4,4);
map.put(5,5);
map.put(6,6);

Set<Integer> keys = map.keySet();
for (Integer key : keys) {
    System.out.println(key);
}
Collection<Integer> values = map.values();
for (Integer value : values) {
    System.out.println(value);
}

● 使用 Iterator 迭代器迭代

Iterator iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<Integer, Integer> mapEntry = (Map.Entry<Integer, Integer>) iterator.next();
    System.out.println(mapEntry.getKey() + " === " + mapEntry.getValue());
}

● 使用 get 方式

不建议使用这种方式,因为会迭代两次。KeySet 获取迭代器一次,get又迭代一次

Set<Integer> keySet = map.keySet();
for(Integer item: keySet) {
    System.out.println( item + "====" + map.get(item));
}

jdk1.8 后使用 Map 接口中的默认方法

map.forEach((key,value) -> {
    System.out.println(key + "========" + value);
});

 

2.2.10 HashMap 在 jdk1.7 和 jdk1.8 中的对比

JDK1.8主要解决或优化了一下问题:

  1. resize 扩容优化
  2. 引入了红黑树,目的是避免单条链表过长而影响查询效率,红黑树算法请参考
  3. 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。
不同JDK 1.7JDK 1.8
存储结构数组 + 链表数组 + 链表 + 红黑树
初始化方式单独函数:inflateTable()直接集成到了扩容函数resize()
hash值计算方式扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算
存放数据的规则无冲突时,存放数组;冲突时,存放链表无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8:树化并存放红黑树
插入数据方式头插法(先讲原位置的数据移到后1位,再插入数据到该位置)尾插法(直接插入到链表尾部/红黑树)
扩容后存储位置的计算方式全部按照原来方法进行计算(即hashCode ->> 扰动函数 ->> (h&length-1))按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量)

其他:

https://blog.csdn.net/ThinkWon/article/details/104588551/

 

2.3 HashMap添加的对象为什么要重写 equals 和 hashcode

2.3.1 equals 和  == 的区别

equals 比较的是目标的内容是否相同,而 == 比较的可能是目标的指针指向地址是否相同。

 

2.3.2 为什么要重写 hashCode 和 equals

流程

在将对象作为 hashCode() 的 key 值时,会使用自定义类的 hashCode() 函数计算出对应的数组下标

在找到对应节点后,使用 equals() 来比较当前下标上的节点是否有相同的 key,有则覆盖 value,没有则添加到尾部

● 对象的 hashCode() 的问题

重写 hashCode() 主要是解决参数相同的自定义类,会得到不同的数组下标的问题

对于对象,默认的 hashCode() 方法会根据对象的引用计算出一个散列码(整形值),并被处理形成数组下标。

若存入 HashMap 中的是不同的对象,即使内部的值都是相等的,但是不同对象的引用是不同的,则根据引用得出的 hash 值也是不同,这两个对象都会被存入 HashMap 中,存储在不同的索引位置

得到的 hash 值是不同的


 

● 对象的 equals 的问题

equals() 的问题主要是父类 Object 类的 equals() 方法之只比较了地址,会导致属性相同的不同自定义对象比较时返回 false

在自定义类中,父类为 Object 类,可以查看源码,父类的 equals 只比较了地址:

故当传入的是两个属性相同,但不是同一个对象的自定义类对象时,equals 会返回 false

 

修改方案:

一般 hashCode() 方法和 equals() 方法会一起被重写,使用内部的属性值来作为比较依据

public class MapTest {
    public static void main(String[] args) {
        HashMap<Person, String> map = new HashMap<Person, String>();
        map.put(new Person("001"), "tom");
        map.put(new Person("002"), "juddy");
        map.put(new Person("003"), "alic");
        map.put(new Person("003"), "pink");

        System.out.println(map.toString());
        System.out.println(map.get(new Person("001")));
        System.out.println(map.get(new Person("002")));
        System.out.println(map.get(new Person("003")));
    }
}

class Person {
    private String id;

    public Person(String id) {
        this.id = id;
    }

    @Override
    public int hashCode() {
        return id != null?id.hashCode():0;
    }

@Override
public boolean equals(Object obj) {
    if(this==obj) {
        return true;       //测试检测的对象是否为空,是就返回false
    }
    if(obj==null) {
        return false;       //测试两个对象所属的类是否相同,否则返回false
    }
    if(getClass()!=obj.getClass()) {
        return false;       //对obj进行类型转换以便和类A的对象进行比较
    }
    Person person = (Person)obj;       //对于值可能为null的属性,检测时应使用Object的equals方法,不为null的可以直接使用==检测
    return Objects.equals(id, person.id);
}
}

2.4 HashTable 和 HashMap 的区别

Hashtable 和 HashMap 的使用方式相同,但 HashTable 已经不建议使用了,要保证线程安全应使用 ConcurrentHashMap

区别是什么:

● 线程安全

HashMap 是非线程安全的,而 HashTable 是线程安全的。HashTable 内部的关键方法都经过 synchronized。

● 效率

由于线程安全问题,HashTable 效率低于 HashMap

● 对 NULL 的支持

HashMap 支持存储 NULL,而 HashTable 不支持

初始容量大小和扩充容量不同

HashMap 默认大小为16,每次扩容为扩大一倍。而 HashTable 默认大小为11,每次扩充为 2n+1

底层数据结构

HashMap 在 jdk1.8 之后引入了红黑树,而 HashTable 没有

● 父类不同

HashTable 继承自 Dictionary 类,而 HashMap 是 Map 接口的一个实现

 

2.5 LinkedHashMap

LInkedHashMap 结合了 HashMap 和 LinkedList,实现了一个有序的 Map。虽然其增加了时间和空间上的开销,但通过维护一个运行于所有条目的双向链表,LinkedHashMap 保证了元素迭代的顺序,该迭代顺序可以是插入顺序,也可以是访问顺序。

 

2.6 TreeMap

TreeMap 是完全的一个红黑树,适用于对一个有序key进行遍历。而 HashMap 更适用于插入、删除、定位元素

 

2.7 ConcurrentHashMap

ConcurrentHashMap 通过分段锁机制实现线程安全,效率高于 HashTable。

由于 HashTable 中只有一把锁,所有的并发线程都必须同时竞争一把锁。而 ConcurrentHashMap 将数据分段,每段数据持有一把自己的锁,这样就允许多个并发线程同时访问,同时也保证了线程安全。

 

三、Set 类

3.1 概念

Set 是一个不存储重复元素的集合,有无序、值不能重复的特点

 

3.2 HashSet

HashSet 是 Set 的实现类,存储无序、不重复的元素的集合,但与 HashMap 类似,由于 HashSet 判断值是否相等是通过比较其hash 值是否相同来判断的,故如果要存储的是自定义类的话,需要重写自定义类的 hashCode() 和 equals() 方法

● HashSet 如何检查重复

先对传入的对象调用 hashCode() 来获取 hash 值,并以此确定元素在内存中的位置。

但是一个存储位置上可能存在多个元素,这时候需要使用 equals() 方法对位置上已存储的元素,与传入的新对象进行比较。

若相同,则返回存入失败,若不相同,则存入 HashSet 对象中

 

3.3 TreeSet

TreeSet 的本质是一个"有序的,并且没有重复元素"的集合,它是通过TreeMap实现的。

使用方式:

public class TestList2 {

    public static void main(String[] args) {
              testTreeSetAPIs();
            }

           // 测试TreeSet的api
           public static void testTreeSetAPIs() {
               String val;
               // 新建TreeSet
                 TreeSet tSet = new TreeSet();
                // 将元素添加到TreeSet中
                tSet.add("aaa");
                 // Set中不允许重复元素,所以只会保存一个“aaa”
                 tSet.add("aaa");
                 tSet.add("bbb");
                 tSet.add("eee");
                 tSet.add("ddd");
                 tSet.add("ccc");
                 System.out.println("TreeSet:"+tSet);
                // 打印TreeSet的实际大小
               System.out.printf("size : %d\n", tSet.size()); // 导航方法
               // floor(小于、等于)
               System.out.printf("floor bbb: %s\n", tSet.floor("bbb"));
               // lower(小于)
               System.out.printf("lower bbb: %s\n", tSet.lower("bbb"));
               // ceiling(大于、等于)
                 System.out.printf("ceiling bbb: %s\n", tSet.ceiling("bbb"));
                System.out.printf("ceiling eee: %s\n", tSet.ceiling("eee"));
                 // ceiling(大于)
                System.out.printf("higher bbb: %s\n", tSet.higher("bbb"));
                // subSet()
                 System.out.printf("subSet(aaa, true, ccc, true): %s\n", tSet.subSet("aaa", true, "ccc", true));
                 System.out.printf("subSet(aaa, true, ccc, false): %s\n", tSet.subSet("aaa", true, "ccc", false));
                 System.out.printf("subSet(aaa, false, ccc, true): %s\n", tSet.subSet("aaa", false, "ccc", true));

                 System.out.printf("subSet(aaa, false, ccc, false): %s\n", tSet.subSet("aaa", false, "ccc", false));
                 // headSet()
                 System.out.printf("headSet(ccc, true): %s\n", tSet.headSet("ccc", true));
                System.out.printf("headSet(ccc, false): %s\n", tSet.headSet("ccc", false));
                 // tailSet()
                 System.out.printf("tailSet(ccc, true): %s\n", tSet.tailSet("ccc", true));
                 System.out.printf("tailSet(ccc, false): %s\n", tSet.tailSet("ccc", false));

                 // 删除“ccc”
                 tSet.remove("ccc");
                 // 将Set转换为数组
                 String[] arr = (String[])tSet.toArray(new String[0]);
                 for (String str:arr)
                         System.out.printf("for each : %s\n", str);

                 // 打印TreeSet
                 System.out.printf("TreeSet:%s\n", tSet);
                         // 遍历TreeSet
                 for(Iterator iter = tSet.iterator(); iter.hasNext(); ) {
                         System.out.printf("iter : %s\n", iter.next());
                     }

                 // 删除并返回第一个元素
                 val = (String)tSet.pollFirst();
                 System.out.printf("pollFirst=%s, set=%s\n", val, tSet);

                 // 删除并返回最后一个元素
                val = (String)tSet.pollLast();
                 System.out.printf("pollLast=%s, set=%s\n", val, tSet);

                 // 清空HashSet
                 tSet.clear();

                 // 输出HashSet是否为空
                 System.out.printf("%s\n", tSet.isEmpty()?"set is empty":"set is not empty");
             }
}

 

3.4 LinkedHashSet

LInkedHashSet 结合了 HashSet 和 LinkedList,实现了一个有序的 Set。虽然其增加了时间和空间上的开销,但通过维护一个运行于所有条目的双向链表,LinkedHashSet 保证了元素迭代的顺序,该迭代顺序可以是插入顺序,也可以是访问顺序。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值