java 集合,也称作容器,主要是由两大接口 (interface) 派生出来的:
collection 和 map
顾名思义,容器就是用来存放数据的。
那么这两大接口的不同之处在于:
collection 存放单一元素;map 存放 key-value 键值对。就是单身狗放 collection 里面,couple 就放 map 里。(所以你属于哪里?
学习这些集合框架,我认为有 4 个目标:
明确每个接口和类的对应关系;对每个接口和类,熟悉常用的 api;对不同的场景,能够选择合适的数据结构并分析优缺点;学习源码的设计,面试要会答啊。关于 map,之前那篇 hashmap 的文章已经讲的非常透彻详尽了,所以本文不再赘述。如果还没看过那篇文章的小伙伴,快去公众号内回复「hashmap」看文章吧~
collection 先来看最上层的 collection.
collection 里还定义了很多方法,这些方法也都会继承到各个子接口和实现类里,而这些 api 的使用也是日常工作和面试常见常考的,所以我们先来看下这些方法。
操作集合,无非就是「增删改查」四大类,也叫 crud:
create, read, update, and delete.
那我也把这些 api 分为这四大类:
功能方法
增 add()/addall()
删 remove()/ removeall()
改 collection interface 里没有
查 contains()/ containsall()
其他 isempty()/size()/toarray()
下面具体来看:
增:boolean add(e e);
add() 方法传入的数据类型必须是 object,所以当写入基本数据类型的时候,会做自动装箱 auto-boxing 和自动拆箱 unboxing。
还有另外一个方法 addall(),可以把另一个集合里的元素加到此集合中。
boolean addall(collection<? extends e> c);
删:boolean remove(object o);
remove()是删除的指定元素。
那和 addall() 对应的,
自然就有removeall(),就是把集合 b 中的所有元素都删掉。
boolean removeall(collection<?> c);
改:collection interface 里并没有直接改元素的操作,反正删和增就可以完成改了嘛!
查:查下集合中有没有某个特定的元素:boolean contains(object o);
查集合 a 是否包含了集合 b:boolean containsall(collection<?> c);
还有一些对集合整体的操作:判断集合是否为空:boolean isempty();
集合的大小:int size();
把集合转成数组:object[] toarray();
以上就是 collection 中常用的 api 了。
在接口里都定义好了,子类不要也得要。
当然子类也会做一些自己的实现,这样就有了不同的数据结构。
那我们一个个来看。
list list 最大的特点就是:有序,可重复。
看官网说的:
an ordered collection (also known as a sequence).
unlike sets, lists typically allow duplicate elements.
这一下把 set 的特点也说出来了,和 list 完全相反,set 是 无序,不重复的。
list 的实现方式有 linkedlist 和 arraylist 两种,那面试时最常问的就是这两个数据结构如何选择。
对于这类选择问题:
一是考虑数据结构是否能完成需要的功能;
如果都能完成,二是考虑哪种更高效。
(万事都是如此啊。
那具体来看这两个 classes 的 api 和它们的时间复杂度:
功能方法arraylistlinkedlist
增 add(e e) o(1) o(1)
增 add(int index, e e) o(n) o(n)
删 remove(int index) o(n) o(n)
删 remove(e e) o(n) o(n)
改 set(int index, e e) o(1) o(n)
查 get(int index) o(1) o(n)
稍微解释几个:
add(e e) 是在尾巴上加元素,虽然 arraylist 可能会有扩容的情况出现,但是均摊复杂度(amortized time complexity)还是 o(1) 的。
add(int index, e e)是在特定的位置上加元素,linkedlist 需要先找到这个位置,再加上这个元素,虽然单纯的「加」这个动作是 o(1) 的,但是要找到这个位置还是 o(n) 的。(这个有的人就认为是 o(1),和面试官解释清楚就行了,拒绝扛精。
remove(int index)是 remove 这个 index 上的元素,所以
arraylist 找到这个元素的过程是 o(1),但是 remove 之后,后续元素都要往前移动一位,所以均摊复杂度是 o(n);linkedlist 也是要先找到这个 index,这个过程是 o(n) 的,所以整体也是 o(n)。remove(e e)是 remove 见到的第一个这个元素,那么
arraylist 要先找到这个元素,这个过程是 o(n),然后移除后还要往前移一位,这个更是 o(n),总的还是 o(n);linkedlist 也是要先找,这个过程是 o(n),然后移走,这个过程是 o(1),总的是 o(n).那造成时间复杂度的区别的原因是什么呢?
答:
因为 arraylist 是用数组来实现的。
而数组和链表的最大区别就是数组是可以随机访问的(random access)。
这个特点造成了在数组里可以通过下标用 o(1) 的时间拿到任何位置的数,而链表则做不到,只能从头开始逐个遍历。
也就是说在「改查」这两个功能上,因为数组能够随机访问,所以 arraylist 的效率高。
那「增删」呢?
如果不考虑找到这个元素的时间,
数组因为物理上的连续性,当要增删元素时,在尾部还好,但是其他地方就会导致后续元素都要移动,所以效率较低;而链表则可以轻松的断开和下一个元素的连接,直接插入新元素或者移除旧元素。
但是呢,实际上你不能不考虑找到元素的时间啊。。。而且如果是在尾部操作,数据量大时 arraylist 会更快的。
所以说:
改查选择 arraylist;增删在尾部的选择 arraylist;其他情况下,如果时间复杂度一样,推荐选择 arraylist,因为 overhead 更小,或者说内存使用更有效率。vector那作为 list 的最后一个知识点,我们来聊一下 vector。这也是一个年龄暴露帖,用过的都是大佬。
那 vector 和 arraylist 一样,也是继承自 java.util.abstractlist,底层也是用数组来实现的。
但是现在已经被弃用了,因为...它加了太多的 synchronized!
任何好处都是有代价的,线程安全的成本就是效率低,在某些系统里很容易成为瓶颈,所以现在大家不再在数据结构的层面加 synchronized,而是把这个任务转移给我们程序员==
那么面试常问题:vector 和 arraylist 的区别是什么,只答出来这个还还不太全面。
来看 stack overflow 上的高票回答:
一是刚才已经说过的线程安全问题;
二是扩容时扩多少的区别。
这个得看看源码:
这是 arraylist 的扩容实现,这个算术右移操作是把这个数的二进制往右移动一位,最左边补符号位,但是因为容量没有负数,所以还是补 0.
那右移一位的效果就是除以 2,那么定义的新容量就是原容量的 1.5 倍。
再来看 vector 的:
因为通常 capacityincrement 我们并不定义,所以默认情况下它是扩容两倍。
答出来这两点,就肯定没问题了。
queue & deque queue 是一端进另一端出的线性数据结构;而 deque 是两端都可以进出的。
queuejava 中的 这个 queue 接口稍微有点坑,一般来说队列的语义都是先进先出(fifo)的。
但是这里有个例外,就是 priorityqueue,也叫 heap,并不按照进去的时间顺序出来,而是按照规定的优先级出去,并且它的操作并不是 o(1) 的,时间复杂度的计算稍微有点复杂,我们之后单独开一篇来讲。
那 queue 的方法官网[1]都总结好了,它有两组 api,基本功能是一样的,但是呢:
一组是会抛异常的;另一组会返回一个特殊值。功能抛异常返回值
增 add(e) offer(e)
删 remove() poll()
瞧 element() peek()
为什么会抛异常呢?
比如队列空了,那 remove() 就会抛异常,但是 poll() 就返回 null;element() 就会抛异常,而 peek() 就返回 null 就好了。那 add(e) 怎么会抛异常呢?
有些 queue 它会有容量的限制,比如 blockingqueue,那如果已经达到了它最大的容量且不会扩容的,就会抛异常;但如果 offer(e),就会 return false.
那怎么选择呢?:
首先,要用就用同一组 api,前后要统一;
其次,根据需求。如果你需要它抛异常,那就是用抛异常的;不过做算法题时基本不用,所以选那组返回特殊值的就好了。
dequedeque 是两端都可以进出的,那自然是有针对 first 端的操作和对 last 端的操作,那每端都有两组,一组抛异常,一组返回特殊值:
功能抛异常返回值
增 addfirst(e)/ addlast(e) offerfirst(e)/ offerlast(e)
删 removefirst()/ removelast() pollfirst()/ polllast()
瞧 getfirst()/ getlast() peekfirst()/ peeklast()
使用时同理,要用就用同一组。
queue 和 deque 的这些 api 都是 o(1) 的时间复杂度,准确来说是均摊时间复杂度。
实现类它们的实现类有这三个:
所以说,
如果想实现「普通队列 - 先进先出」的语义,就使用 linkedlist 或者 arraydeque 来实现;如果想实现「优先队列」的语义,就使用 priorityqueue;如果想实现「栈」的语义,就使用 arraydeque。我们一个个来看。
在实现普通队列时,如何选择用 linkedlist 还是 arraydeque 呢?
来看一下 stackoverflow[2] 上的高票回答:
总结来说就是推荐使用 arraydeque,因为效率高,而 linkedlist 还会有其他的额外开销(overhead)。
那 arraydeque 和 linkedlist 的区别有哪些呢?
还是在刚才的同一个问题下,这是我认为总结的最好的:
arraydeque 是一个可扩容的数组,linkedlist 是链表结构;arraydeque 里不可以存 null 值,但是 linkedlist 可以;arraydeque 在操作头尾端的增删操作时更高效,但是 linkedlist 只有在当要移除中间某个元素且已经找到了这个元素后的移除才是 o(1) 的;arraydeque 在内存使用方面更高效。所以,只要不是必须要存 null 值,就选择 arraydeque 吧!
那如果是一个很资深的面试官问你,什么情况下你要选择用 linkedlist 呢?
答:java 6 以前。。。因为 arraydeque 在 java 6 之后才有的。。为了版本兼容的问题,实际工作中我们不得不做一些妥协。。
那最后一个问题,就是关于 stack 了。
stackstack 在语义上是 后进先出(lifo) 的线性数据结构。
有很多高频面试题都是要用到栈的,比如接水问题,虽然最优解是用双指针,但是用栈是最直观的解法也是需要了解的,之后有机会再专门写吧。
那在 java 中是怎么实现栈的呢?
虽然 java 中有 stack 这个类,但是呢,官方文档都说不让用了!
原因也很简单,因为 vector 已经过被弃用了,而 stack 是继承 vector 的。
那么想实现 stack 的语义,就用 arraydeque 吧:
deque<integer> stack = new arraydeque<>();
set 最后一个 set,刚才已经说过了 set 的特定是无序,不重复的。
就和数学里学的「集合」的概念一致。
set 的常用实现类有三个:
hashset: 采用 hashmap 的 key 来储存元素,主要特点是无序的,基本操作都是 o(1) 的时间复杂度,很快。
linkedhashset: 这个是一个 hashset + linkedlist 的结构,特点就是既拥有了 o(1) 的时间复杂度,又能够保留插入的顺序。
treeset: 采用红黑树结构,特点是可以有序,可以用自然排序或者自定义比较器来排序;缺点就是查询速度没有 hashset 快。
那每个 set 的底层实现其实就是对应的 map:
数值放在 map 中的 key 上,value 上放了个 present,是一个静态的 object,相当于 place holder,每个 key 都指向这个 object。
那么具体的实现原理、增删改查四种操作,以及哈希冲突、hashcode()/equals() 等问题都在 hashmap 那篇文章里讲过了,这里就不赘述了,没有看过的小伙伴可以在公众号后台回复「hashmap」获取文章哦~
总结 再回到开篇的这张图,有没有清楚了一些呢?
每个数据结构下面其实都有很多内容,比如 priorityqueue 本文没有细说,因为这家伙一说又要半天。。
如果你觉得文章不错,文末的赞 ? 又回来啦,记得给我「点赞」和「在看」哦~
最后呢,很多读者一直有问我交流群的问题,因为考虑到我有时差不方便管理,所以一直没做。
但现在我找了一个专业的管理员和我一起管理,所以「齐姐的秘密基地」正在筹备中,并且我会邀请国内外的一些大佬入场,给大家带来不一样的视角。
那么第一期交流群计划于 7 月中上旬开放,到时候我会在朋友圈发邀请,大家敬请期待!
以上就是java 集合框架看这一篇就够了的详细内容。
