[java数据结构分析]HashSet , TreeSet分析

HashSet

简介:

  1. 底层实现基于 HashMap,所以迭代时不能保证按照插入顺序,或者其它顺序进行迭代;
  2. add、remove、contanins、size 等方法的耗时性能,是不会随着数据量的增加而增加的,
    这个主要跟 HashMap 底层的数组数据结构有关,不管数据量多大,不考虑 hash 冲突的情
    况下,时间复杂度都是 O (1);
  3. 线程不安全的,如果需要安全请自行加锁,或者使用 Collections.synchronizedSet;
  4. 迭代过程中,如果数据结构被改变,会快速失败的,会抛出
    ConcurrentModificationException 异常。

HashSet是如何组合HashMap的?
一般我们实现有两种方式:
继承基础类,覆写基础类的方法,比如说继承 HashMap , 覆写其 add 的方法;
组合基础类,通过调用基础类的方法,来复用基础类的能力。
HashSet 使用的就是组合 HashMap,其优点如下:

  1. 继承表示父子类是同一个事物,而 Set 和 Map 本来就是想表达两种事物,所以继承不妥,
    而且 Java 语法限制,子类只能继承一个父类,后续难以扩展。
  2. 组合更加灵活,可以任意的组合现有的基础类,并且可以在基础类方法的基础上进行扩展、编排等,而且方法命名可以任意命名,无需和基础类的方法名称保持一致。

我们在工作中,如果碰到类似问题,我们的原则也是尽量多用组合,少用继承。

组合就是把 HashMap 当作自己的一个局部变量,以下是 HashSet 的组合实现:
在这里插入图片描述
从上述代码我们可以看出:

  1. 我们在使用 HashSet 时,比如 add 方法,只有一个入参,但组合的 Map 的 add 方法却有key,value 两个入参,相对应上 Map 的 key 就是我们 add 的入参,value 就是第二行代码中的 PRESENT,此处设计非常巧妙,用一个默认值 PRESENT 来代替 Map 的 Value;
  2. 如果 HashSet 是被共享的,当多个线程访问的时候,就会有线程安全问题,因为在后续的所有操作中,并没有加锁;

HashSet 在以 HashMap 为基础进行实现的时候,首先选择组合的方式,接着使用默认值来代替了 Map 中的 Value 值,设计得非常巧妙,给使用者的体验很好,使用起来简单方便,我们在工作中也可以借鉴这种思想,可以把底层复杂实现包装一下,一些默认实现可以自己吃掉,使吐出去的接口尽量简单好用。

TreeSet

TreeSet 大致的结构和 HashSet 相似,底层组合的是 TreeMap,所以继承了 TreeMap key能够排序的功能,迭代的时候,也可以按照 key 的排序顺序进行迭代,我们主要来看复用TreeMap 时,复用的两种思路:

2.1 复用 TreeMap 的思路一

场景一: TreeSet 的 add 方法,我们来看下其源码:在这里插入图片描述
可以看到,底层直接使用的是 HashMap 的 put 的能力,直接拿来用就好了

场景二:需要迭代 TreeSet 中的元素,那应该也是像 add 那样,直接使用 HashMap 已有的迭代能力,比如像下面这样:

public Iterator<E> descendingIterator() {
// 直接使用 HashMap.keySet 的迭代能力
return m.keySet().iterator();
}

这种是思路一的实现方式,TreeSet 组合 TreeMap,直接选择 TreeMap 的底层能力进行包装,但 TreeSet 实际执行的思路却完全相反,我们看源码
在这里插入图片描述
我们看到的是TreeSet定义了规范,而实现是由TreeMap的子类实现的。
在这里插入图片描述

我们总结下 TreeSet 组合 TreeMap 实现的两种思路:

  1. TreeSet 直接使用 TreeMap 的某些功能,自己包装成新的 api。

  2. TreeSet 定义自己想要的 api,自己定义接口规范,让 TreeMap 去实现。
    的定义和实现都在 TreeMap,TreeSet 只是简单的调用而已,第二种 TreeSet 把接口定义出来后,让 TreeMap 去实现内部逻辑,TreeSet 负责接口定义,TreeMap 负责具体实现,这样子的话因为接口是 TreeSet 定义的,所以实现一定是 TreeSet 最想要的,TreeSet 甚至都不用包装,可以直接把返回值吐出去都行。

我们思考下这两种复用思路的原因:

像 add 这些简单的方法,我们直接使用的是思路 1,主要是 add 这些方法实现比较简单,没有复杂逻辑,所以 TreeSet 自己实现起来比较简单;

思路 2 主要适用于复杂场景,比如说迭代场景,TreeSet 的场景复杂,比如要能从头开始迭代,比如要能取第一个值,比如要能取最后一个值,再加上 TreeMap 底层结构比较复杂,TreeSet 可能并不清楚 TreeMap 底层的复杂逻辑,这时候让 TreeSet 来实现如此复杂的场景逻辑,TreeSet 就搞不定了,不如接口让 TreeSet 来定义,让 TreeMap 去负责实现,TreeMap 对底层的复杂结构非常清楚,实现起来既准确又简单。

TreeSet 有用过么,平时都在什么场景下使用?
答:有木有用过如实回答就好了,我们一般都是在需要把元素进行排序的时候使用 TreeSet,使用时需要我们注意元素最好实现 Comparable 接口,这样方便底层的 TreeMap 根据 key 进行排序。

追问,如果我想实现根据 key 的新增顺序进行遍历怎么办?
要按照 key 的新增顺序进行遍历,首先想到的应该就是 LinkedHashMap,而
LinkedHashSet 正 好 是 基 于 LinkedHashMap 实 现 的 , 所 以 我 们 可 以 选 择 使 用LinkedHashSet。

如果我想对 key 进行去重,有什么好的办法么?
我们首先想到的是 TreeSet,TreeSet 底层使用的是 TreeMap,TreeMap 在 put 的时候,如果发现 key 是相同的,会把 value 值进行覆盖,所有不会产生重复的 key ,利用这一特性,使用 TreeSet 正好可以去重。

说说 TreeSet 和 HashSet 两个 Set 的内部实现结构和原理?
答:这两种Set中底层都封装了响应的Map,对于Set是单个值,而Map是键值对的解决方式是因为我们创建了一个默认的value,每次使用map的方法去完成set的功能的时候就会使用默认的value。HashSet 底层对 HashMap 的能力进行封装,比如说 add 方法,是直接使用 HashMap的 put 方法,而TreeSet底层是对TreeMap的能力进行封装,而TreeMap其实使用的还是HashMap的put方法。还有一个印象比较深刻的就是发现TreeSet中的遍历方法的实现,实际上暴露一个接口,让TreeMap中的子类进行实现,而自己只定义规范。

看集合的源码分析出来的对实际工作的帮助:

1.批量增加的性能高于单个不断增加,单个不断增加扩容问题太多。

2.批量删除的性能高于单个不断删除,因为每次remove都会发生一次copy数组
这样一个一个删除,性能太差!

3.为了避免频繁扩容,我们可以考虑为HashMap加上初始值:
给 HashMap 赋初始值的公式为:取括号内两者的最大值(期望的值/0.75+1,默认值 16)

4.所有集合类,在 for 循环进行删除时,如果直接使用集合类的 remove 方法进行删除,都会快速失败,报 ConcurrentModificationException 的错误,所以在任意循环删除的场景下,都建议使用迭代器进行删除;

5.我们把数组转化成集合时,常使用 Arrays.asList(array),这个方法有两个坑,
代码演示坑为:

public void testArrayToList(){

	Integer[] array = new Integer[]{1,2,3,4,5,6};
	List<Integer> list = Arrays.asList(array);
	// 坑1:修改数组的值,会直接影响原 list
	log.info("数组被修改之前,集合第一个元素为:{}",list.get(0));
	array[0] = 10;
	log.info("数组被修改之前,集合第一个元素为:{}",list.get(0));
	// 坑2:使用 add、remove 等操作 list 的方法时,
	// 会报 UnsupportedOperationException 异常
	list.add(7);
}1:数组被修改后,会直接影响到新 List 的值。
坑 2:不能对新 List 进行 add、remove 等操作,否则运行时会报 UnsupportedOperationExceptio

Arrays.asList 方法返回的 List 并不是 java.util.ArrayList,而是自己内部的一个静态类,该静态类直接持有数组的引用,并且没有实现 add、remove 等方法,
这些就是坑 1 和 2 的原因。
在这里插入图片描述
从上图中,我们可以发现,Arrays.asList 方法返回的 List 并不是java.util.ArrayList,而是自己内部的一个静态类,该静态类直接持有数组的引用,并且没有实现 add、remove 等方法,这些就是坑 1 和 2 的原因。

public void testListToArray(){
List<Integer> list = new ArrayList<Integer>(){{
	add(1);
	add(2);
	add(3);
	add(4);
}};
// 下面这行被注释的代码这么写是无法转化成数组的,无参 toArray 返回的是 Object[],
// 无法向下转化成 List<Integer>,编译都无法通过
// List<Integer> list2 = list.toArray();
// 演示有参 toArray 方法,数组大小不够时,得到数组为 null 情况
	Integer[] array0 = new Integer[2];
	list.toArray(array0);
	log.info("toArray 数组大小不够,array0 数组[0] 值是{},数组[1] 值是{},",array0[0],array0[1]);
	// 演示数组初始化大小正好,正好转化成数组
	Integer[] array1 = new Integer[list.size()];
	list.toArray(array1);
	log.info("toArray 数组大小正好,array1 数组[3] 值是{}",array1[3]);
	// 演示数组初始化大小大于实际所需大小,也可以转化成数组
	Integer[] array2 = new Integer[list.size()+2];
	list.toArray(array2);
	log.info("toArray 数组大小多了,array2 数组[3] 值是{},数组[4] 值是{}",array2[3],array2[4]);
}
19:33:07.687 [main] INFO demo.one.ArrayListDemo - toArray 数组大小不够,array0 数组[0]19:33:07.697 [main] INFO demo.one.ArrayListDemo - toArray 数组大小正好,array1 数组[3]19:33:07.697 [main] INFO demo.one.ArrayListDemo - toArray 数组大小多了,array2 数组[3]

toArray 的无参方法,无法强转成具体类型,这个编译的时候,就会有提醒,我们一般都会去使用带有参数的 toArray 方法,这时就有一个坑,如果参数数组的大小不够,这时候返回的数组值竟然是空,上述代码中的 array0 的返回值就体现了这点,但我们去看 toArray 源码,发现源码中返回的是 4 个大小值的数据,返回的并不是空,源码如下:

// List 转化成数组
public <T> T[] toArray(T[] a) {
// 如果数组长度不够,按照 List 的大小进行拷贝,return 的时候返回的都是正确的数组
	if (a.length < size)
	// Make a new array of a's runtime type, but my contents:
		return (T[]) Arrays.copyOf(elementData, size, a.getClass());
	System.arraycopy(elementData, 0, a, 0, size);
	// 数组长度大于 List 大小的,赋值为 null
	if (a.length > size)
		a[size] = null;
	return a;
}

从源码中,我们丝毫看不出为什么 array0 的元素值为什么是 null,最后我们去看方法的注释,
发现是这样子描述的:

在这里插入图片描述
适合则直接返回,不适合则返回一个与list大小相同的array。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值