Java 集合框架
将集合的接口与实现分离
Java集合类库将接口(interface)与实现(implementation)分离。
以队列接口为例,队列接口需要实现的有:
- 在头部删除元素
- 在尾部增加元素
- 查找队列中元素的个数
所以队列接口的最简单形式可能类似下面这样:
java.util.Queue
|
|
Queue接口规定了队列的基本功能,具体实现有两种方式:循环数组和链表,可以通过实现Queue接口创建队列。
Java类库中,需要循环数组可以使用ArrayDeque类,需要链表队列可以使用LinkedList类。
一般将集合的引用赋值给接口类型:Queue<Customer> expressLane = new CircularArrayQueue<>(100)
Java类库中还有一个AbstractQueue
类,是在Queue上的进一步封装,当我们需要编写自己实现的队列时,继承AbstractQueue
会比实现Queue
接口好得多
java.util.AbstractQueue
|
|
Collection接口
Java类库中集合类的基本接口时Collection接口,这个接口有两个基本方法:
|
|
iterator()方法 返回一个已经实现的迭代器,可以遍历集合。
迭代器
|
|
next
方法获取下个元素,如果没有下个元素则报NoSuchElementException
异常,所以在使用next
方法前需要使用hasNext()
判断。foreach
可以与任何实现了Iterable接口的对象一起工作,Collection接口扩展了Iterable接口。因此,对于标准类库中的的任何集合都可以使用foreach
remove
方法会伤处上次调用next方法时返回的元素,不能连续两次使用remove,使用remove前必须先使用next越过需要删除的元素。
集合框架中的接口
Collection
和map
是集合的两个基本接口,对集合来说,添加元素只需要给定一个元素类型,所以使用add方法,对于映射来说,则需要Key的类型和Value的类型所以用put方法。
List是有序集合,元素会 被添加到特定位置,具体实现有ArrayList
和LinkedList
,前者是基于数组实现,在遍历时推荐使用整数索引(又称随机访问Random Access),后者基于链表实现,遍历时最好使用迭代器访问。
查看Java类库中ArrayList
类的定义,发现:public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, Serializable
ArrayList
类实现了RandomAccess
接口,这个接口时标记接口,不含有任何方法,只是用以表明这个类是否支持快速随机访问。
通过:
|
|
就可以采用适合的访问方式。
Set接口等同于Collection接口,不过set中的add方法不允许增加重复的元素。源码中Set接口扩展了Collection接口,但是它本身并没有对这些接口做真正的改动,真正做了改动的是AbstractSet
类,其中重写了hashCode
、equal
、removeAll
方法.
说下前两个重写,因为集合是无序存储,所以集合的判等应该是其中所有元素都是相同的就视为相等,因此在使用equal
时需要处理一下,看源码:
|
|
这里看到Set类的hashCode是所有子元素的hashCode和,此外,equals中,只要是扩展了Set接口的类,就可以通过第一层判定,比如HashSet和TreeSet。
然后是一个类型转换,containAll方法(O(n2))调用。
具体的集合
链表 LinkedList
Java中的链表实际上都是双向链表,使用ListIterator
可以访问前驱结点。
调用previous
后如果调用了remove
就会删除光标迭代器右侧的元素。
我们知道链表中使用整数索引访问的话效率会很低,虽然LinkedList提供了整数索引方法,但是其本质还是从头开始一个个访问:
|
|
数组列表ArrayList
ArrayList封装了一个动态再分配的对象数组,可以使得随机访问更加的快速。
还有另一个动态数组是Vector类,两者区别是,Vector中所有方法都是同步的,可以让两个线程安全地访问同一个对象。但是对单线程来说,在同步上需要耗费很多时间,所以在单线程或者不需要同步的场景还是使用ArrayList比较好。
散列集
散列集基于散列表实现,散列表为每个对象计算一个HashCode,在自定义类中,如果重写了equals方法,那必须重写HashCode以保证,equals结果为true时,hashcode一定相同。
上面说到AbstractSet的重写,就拿AbstractSet举例:
可以看到set1,set2的hashCode相同,equals结果相同,这是因为,AbstractSet重写了这两个方法,使得对含有相同元素的两个set,返回相同的hashCode,并使得equals结果为True。
但是实际上,set1,set2在堆内存中是两个不同的对象。
如果不重写这两个方法的话,默认采用Object类中的equals和hashCode:
|
|
hashCode为本地方法,由虚拟机为我们生成,应该是根据对象的内存地址生成的HashCode,同时equals用==
判断,实际上就是比较两个对象的内存地址是否相同,换言之,是否是同一个对象。
如果不重写的话,那么上面截图中的结果都是false,因为这是两个对象,有不同的内存地址。
那么有个问题是,既然equals结果为true,hashcode一定相同,为什么不再equals方法中直接判断hashcode是否相同呢,原因就是,hashcode有可能冲突,就是不同的两个对象产生了相同的hashCode。
HashCode和equals的结果应该满足如下关系:
- equals为true时,hashCode一定相等
- hashCode相等时,equals不一定为true
所以在重写equals时,可以先比较hashCode是否相同,如果不相同的话,那就直接返回False,如果相同,那就再执行判断。
散列表
Java中散列表用链表数组实现就是数组+链表的形式。每个列表被成为桶,先对存入元素计算hashcode,然后将这个hashcode插入对应的桶中。
取元素时,根据元素对应的hashcode获得所在桶(随机访问数组),然后遍历桶(迭代器访问链表),这样就很好利用了两者的优点,但是同时,hashcode的计算方法也至关重要,一般会将(Java类库中就是)桶的大小设置为2的幂,因为这样的话,可以使用效率极快的位运算来计算hashcode,而位运算的结果可能性就是2的幂。
通俗的说,散列表的原理和字典目录差不多,假设我们在编排字典,插入一个汉字时,先看这个字的拼音首字母(计算Hashcode),然后根据首字母找到对应的一级目录(桶),将这个字插入到这个一级目录下(插入桶);如果需要查某个字,先看这个字的首字母,根据这个首字母找到对应的一级目录(桶的随机访问),然后在这个以及目录下一个个找到那个字(链表的迭代访问)。
当加入的字过多时,桶的大小可能会变得超出预期,所以我们需要设计好编排规则,除了只看拼音首字母,还可以看之后的若干字母,建立多级目录(也就是建立多级桶)。
HashSet
HashSet
是Java类库中一个实现了散列表的集,计算hashCode的依据是存入对象的内存地址。
TreeSet
TreeSet
较散列集有所改进,树集是一个有序集合,可以以任意顺序将元素插入到集合中,这个排序基于红黑树实现,将一个元素添加到树集中比添加到散列表中慢。
要使用树集,必须要能够比较元素,这些元素必须实现Comparable接口,或者构造集时必须提供一个Comparator。
队列与双端队列
通过继承Deque
接口实现一个双端队列, Java类库中,ArrayDeque和LinkedList都提供了双端队列。
优先队列
优先队列总是按照排序的顺序进行检索,无论什么时候调用remove总是会获取当前优先队列中最小的元素。优先队列基于堆实现,总是可以让最小的元素移动到根。