集合详解
什么是集合
集合就是由若干个确定的元素所构成的整体。
为什么要引入集合
在数学中,我们经常遇到集合的概念。如:
- 一个班所有同学构成的集合
- 一个网站的所有商品
- 全体自然数
- 有理数集合
- …
那我们为什么要在计算机引入集合呢?这是因为便于处理一组类似的数据。如:
- 计算所有同学的总成绩
- 列举所有商品的价格和名称
- …
在Java中,如果一个Java对象可以在nebula持有若干个其他Java对象,并对外提供访问接口,我们把这种Java对象称为集合。很显然,Java的数组就可以看作一种集合:
String[] s= new String[10]; // 可以持有10个String对象
s[0] = "Hello"; // 可以放入String对象
String first = s[0]; // 可以获取String对象
既然Java提供了数组这种数据类型,可以充当集合,那么,我们为什么还需要其他集合类?这是因为数组有如下限制:
- 数组初始化后大小不可变;
- 数组只能按索引顺序存取。
因此,我们需要各种不同类型的集合类来处理不同的数据,例如:
- 可变大小的顺序链表;
- 保证无重复元素的集合;
- …
Collection
Java标准库自带的java.util包提供了集合类:Collection,它是除Map外所有其他集合类的根接口。Java的java.util包主要提供了以下三种类型的集合:
- List:一种有序列表的集合,例如,按索引排列的Student的List;
- Set:一种保证没有重复元素的集合,例如,所有无重复名称的Student的Set;
- Map:一种通过键值(key-value)查找的映射表集合,例如,根据Student的name查找对应Student的Map。
Java集合的设计有几个特点:一是实现了接口和实现类相分离,例如,有序表的接口是List,具体的实现类有ArrayList,LinkedList等,二是支持泛型,我们可以限制在一个集合中只能放入同一种数据类型的元素。例如:
List<String> list = new ArrayList<>(); // 只能放入String类型
Java访问集合总是通过统一的方式——迭代器(Iterator)来实现,它最明显的好处在于无需知道集合内部元素是按什么方式存储的。
使用List
在集合类中,List是最基础的一种集合:它是一种有序列表。
List接口是Collection接口的子接口,用于定义线性表数据结构。可以将List理解为存放对象的数组,只不过其元素个数可以动态的增加或减少。List有一个重要的实现类–ArrayList类,List中的元素是有序排列的而且可重复,所以被称为是序列。
List可以精确的控制每个元素的插入位置,或删除某个位置元素,它的实现类ArrayList底层是由数组实现的。
List< E >接口,可以看到几个主要的接口方法:
- 在末尾添加一个元素:boolean add(E e)
- 在指定索引添加一个元素:boolean add(int index, E e)
- 删除指定索引的元素:int remove(int index)
- 删除某个元素:int remove(Object e)
- 获取指定索引的元素:E get(int index)
- 获取链表大小(包含元素的个数):int size()
实现List接口并非只能通过数组(即ArrayList的实现方式)来实现,另一种LinkedList通过“链表”也实现了List接口。在LinkedList中,它的内部每个元素都指向下一个元素:
ArrayList和LinkedList的区别:
ArrayList | LinkedList | |
---|---|---|
获取指定元素 | 速度很快 | 需要从头开始查找元素 |
添加元素到末尾 | 速度很快 | 速度很快 |
在指定位置添加/删除 | 需要移动元素 | 不需要移动元素 |
内存占用 | 少 | 较大 |
通常情况下,我们优先使用ArrayList。ArrayList更适合于随机访问,而LinkedList更适合于插入和删除。
又因为List是列表类型,所以List接口还提供了一些适合于自身的常用方法,如表所示(此图借鉴此博客)
这里介绍一下subList:
- List的subList方法用于获取子List。
- 需要注意的是,subList获取的List与原List占有相同的存储空间,对子List的操作会影响原List。
- List < E >subList(int fromIndex, int toIndex);
- fromIndex和toIndex是截取子List的首尾下标(前包括,后不包括)
除了使用ArrayList和LinkedList,我们还可以通过List接口提供的of()方法,根据给定元素快速创建List:
List<Integer> list = List.of(1, 2, 5);
但是List.of()方法不接受null值,如果传入null,会抛出NullPointerException异常。
List的特点
List接口允许我们添加重复的元素,即List内部的元素可以重复:
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple"); // size=1
list.add("pear"); // size=2
list.add("apple"); // 允许重复添加元素,size=3
System.out.println(list.size());
}
}
//结果为:3
List还允许添加null:
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple"); // size=1
list.add(null); // size=2
list.add("pear"); // size=3
String second = list.get(1); // null
System.out.println(second);
}
}
遍历List
对于怎么遍历List,我们可以用for循环根据索引配合get(int)方法遍历:
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
for (String s : list) {
System.out.println(s);
}
}
}
/*结果为:
apple
pear
banana
*/
我们除了使用for each来遍历List之外,还可以使用迭代器Iterator来访问List,虽然比较复杂,但总是具有最高的访问效率。
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
for (int i=0; i<list.size(); i++) {
String s = list.get(i);
System.out.println(s);
}
}
}
对于Iterator与for each,大家可以去看看这篇文章,我感觉挺好的。
List和Array转换
把List变为Array有三种方法。
- 第一种是调用toArray()方法直接返回一个Object[]数组:
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
Object[] array = list.toArray();
for (Object s : array) {
System.out.println(s);
}
}
}
/*结果为:
apple
pear
banana
*/
但是这种方法会丢失类型信息,所以实际应用很少。
- 第二种方式是给toArray(T[])传入一个类型相同的Array,List内部自动把元素复制到传入的Array中:
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Integer> list = List.of(12, 34, 56);
Integer[] array = list.toArray(new Integer[3]);
for (Integer n : array) {
System.out.println(n);
}
}
}
/*
结果为:
12 34 56
*/
对于这个方法,如果我们传入类型不匹配的数组,例如,String[]类型的数组,由于List的元素是Integer,所以无法放入String数组,这个方法会抛出ArrayStoreException。
- 最后一种更简洁的写法是通过List接口定义的T[] toArray(IntFunction<T[]> generator)方法:
Integer[] array = list.toArray(Integer[]::new);
Array转换为List:
- Arrays类中提供了一个静态方法asList,使用该方法我们可以将一个数组转换为对应的List集合。
- 其定义方法为:
static < T >List< T > asList< T…a > - 返回的List集合元素类型由传入的数组的元素类型决定。
- 并且要注意的是,返回的集合我们不能对其增删元素,否则会抛出异常。并且对集合的元素进行修改也会影响数组对应的元素。
public void testArrayToList(){
String[] arr = {"a", "b", "c"};
List<String> list = Arrays.asList(arr);
System.out.println(list);//[a, b, c]
//list.add("d");//会抛出UnsupportedOperationException
System.out.println(list.getClass().getName());
List<String> list1 = new ArrayList<String>();
list1.addAll(Arrays.asList(arr));
编写equals方法
为什么要编写equals方法呢?
因为List内部并不是通过==判断两个元素是否相等,而是使用equals()方法判断两个元素是否相等,例如contains()方法可以实现如下:
public class ArrayList {
Object[] s;
public boolean contains(Object o) {
for (int i = 0; i < size; i++) {
if (o.equals(s[i])) {
return true;
}
}
return false;
}
}
因此,要正确使用List的contains()、indexOf()这些方法,放入的实例必须正确覆写equals()方法,否则,放进去的实例,查找不到。我们之所以能正常放入String、Integer这些对象,是因为Java标准库定义的这些类已经正确实现了equals()方法。
那我们要如何正确编写equals()方法?equals()方法要求我们必须满足以下条件:
- 自反性(Reflexive):对于非null的x来说,x.equals(x)必须返回true
- 对称性(Symmetric):对于非null的x和y来说,如果x.equals(y)为true,则y.equals(x)也必须为true;
- 传递性(Transitive):对于非null的x、y和z来说,如果x.equals(y)为true,y.equals(z)也为true,那么x.equals(z)也必须为true;
- 一致性(Consistent):对于非null的x和y来说,只要x和y状态不变,则x.equals(y)总是一致地返回true或者false;
- 对null的比较:即x.equals(null)永远返回false。
首先,我们要定义“相等”的逻辑含义。对于Person类,如果name相等,并且age相等,我们就认为两个Person实例相等。
因此,编写equals()方法如下:
public boolean equals(Object o) {
if (o instanceof Person) {
Person p = (Person) o;
return this.name.equals(p.name) && this.age == p.age;
}
return false;
}
对于引用字段比较,我们使用equals(),对于基本类型字段的比较,我们使用==。
如果this.name为null,那么equals()方法会报错,因此,需要继续改写如下:
public boolean equals(Object o) {
if (o instanceof Person) {
Person p = (Person) o;
boolean nameEquals = false;
if (this.name == null && p.name == null) {
nameEquals = true;
}
if (this.name != null) {
nameEquals = this.name.equals(p.name);
}
return nameEquals && this.age == p.age;
}
return false;
}
如果Person有好几个引用类型的字段,上面的写法就太复杂了。要简化引用类型的比较,我们使用Objects.equals()静态方法:
public boolean equals(Object o) {
if (o instanceof Person) {
Person p = (Person) o;
return Objects.equals(this.name, p.name) && this.age == p.age;
}
return false;
}
我们总结一下equals()方法的正确编写方法:
- 先确定实例“相等”的逻辑,即哪些字段相等,就认为实例相等;
- 用instanceof判断传入的待比较的Object是不是当前类型,如果是,继续比较,否则,返回false;
- 对引用类型用Objects.equals()比较,对基本类型直接用==比较。
- 使用Objects.equals()比较两个引用类型是否相等的目的是省去了判断null的麻烦。两个引用类型都是null时它们也是相等的。
- 如果不调用List的contains()、indexOf()这些方法,那么放入的元素就不需要实现equals()方法。
这里我们拓展一下equals和==的区别:
1、==用于比较变量的值,可以应用于任何类型,如果用于引用类型,比较的是两个引用变量中存储的值(地址信息),判断两个变量是否指向相同的对象;
2、equals是Object的方法,重写以后,可以用于比较两个对象的内容是否“相等”
3、需要注意的是,Object默认的equals方法的比较规则同 ==
使用Map
我们知道,List是一种顺序列表,如果有一个存储学生Student实例的List,要在List中根据name查找某个指定的Student的分数,应该怎么办?
最简单的方法是遍历List并判断name是否相等,然后返回指定元素。但是使用List来实现存在效率非常低的问题,因为平均需要扫描一半的元素才能确定。
所以我们这里使用Map这种键值(key-value)映射表的数据结构,作用就是能高效通过key快速查找value(元素)。
Map接口定义的集合又称查找表,用于存储所谓“Key-Value”映射对。Key可以看成是Value的索引,作为Key的对象在集合不可以重复。
用Map来实现根据name查询某个Student的代码如下:
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
Student s = new Student("Xiao Ming", 99);
Map<String, Student> map = new HashMap<>();
map.put("Xiao Ming", s); // 将"Xiao Ming"和Student实例映射并关联
Student target = map.get("Xiao Ming"); // 通过key查找并返回映射的Student实例
System.out.println(target == s); // true,同一个实例
System.out.println(target.score); // 99
Student another = map.get("Bob"); // 通过另一个key查找
System.out.println(another); // 未找到返回null
}
}
class Student {
public String name;
public int score;
public Student(String name, int score) {
this.name = name;
this.score = score;
}
}
/*
结果为:
true
99
null
*/
通过上述代码可知:Map<K, V>是一种键-值映射表,当我们调用put(K key, V value)方法时,就把key和value做了映射并放入Map。当我们调用V get(K key)时,就可以通过key获取到对应的value。如果key不存在,则返回null。和List类似,Map也是一个接口,最常用的实现类是 HashMap。
put()方法
-
Map接口中定义了向Map中存放元素的put方法:
-V put (K key, V value) -
将Key-Value对存入Map, 如果在集合中已经包含改Key,则操作将替换该Key所对应的Value,返回值为该Key原来所对应的Value(如果没有则返回null)
public void TextPut(){
//向Map中添加元素
one.put("zhangsan",new Emp("zhangsan",25,"男"));
one.put("lisi",new Emp("lisi",26,"男"));
}
始终牢记:Map中不存在重复的key,因为放入相同的key,只会把原有的key-value对应的value给替换掉。
get()方法
- Map接口中定义了从Map中获取元素的get方法:
-V get(Object key) - 返回参数key所对应的Value对象,如果不存在则返回null
public void TestGet(){
//从Map中使用key获取value
Emp emp = one.get("zhangsan");
System.out.println(emp);
containsKey()方法
- Map接口中定义了判断某个key是否在Map中存在:
-boolean containsKey(Object key); - 若Map中包含给定的Key则返回true,否则返回false
public void Test(){
boolen has = one.containsKey("zhangsan");
System.out.println(has);
编写equals
HashMap之所以能根据key直接拿到value,原因是它内部通过空间换时间的方法,用一个大数组存储所有value,并根据key直接计算出value应该存储在哪个索引:
如果key的值为"a",计算得到的索引总是1,因此返回value为Person(“Xiao Ming”),如果key的值为"b",计算得到的索引总是5,因此返回value为Person(“Xiao Hong”),这样,就不必遍历整个数组,即可直接读取key对应的value。
当我们使用key存取value的时候,就会引出一个问题:
我们放入Map的key是字符串"a",但是,当我们获取Map的value时,传入的变量不一定就是放入的那个key对象。
换句话讲,两个key应该是内容相同,但不一定是同一个对象。测试代码如下:
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
String key1 = "a";
Map<String, Integer> map = new HashMap<>();
map.put(key1, 123);
String key2 = new String("a");
map.get(key2); // 123
System.out.println(key1 == key2); // false
System.out.println(key1.equals(key2)); // true
}
因为在Map的内部,对key做比较是通过equals()实现的,这一点和List查找元素需要正确覆写equals()是一样的,即正确使用Map必须保证:作为key的对象必须正确覆写equals()方法。
我们经常使用String作为key,因为String已经正确覆写了equals()方法。但如果我们放入的key是一个自己写的类,就必须保证正确覆写了equals()方法。
我们再思考一下HashMap为什么能通过key直接计算出value存储的索引。相同的key对象(使用equals()判断时返回true)必须要计算出相同的索引,否则,相同的key每次取出的value就不一定对。
通过key计算索引的方式就是调用key对象的hashCode()方法,它返回一个int整数。HashMap正是通过这个方法直接定位key对应的value的索引,继而直接返回value。
因此,正确使用Map必须保证:
- 作为key的对象必须正确覆写equals()方法,相等的两个key实例调用equals()必须返回true;
- 作为key的对象还必须正确覆写hashCode()方法,且hashCode()方法要严格遵循以下规范:
- 如果两个对象相等,则两个对象的hashCode()必须相等;
- 如果两个对象不相等,则两个对象的hashCode()尽量不要相等。
即对应两个实例a和b:
- 如果a和b相等,那么a.equals(b)一定为true,则a.hashCode()必须等于b.hashCode();
- 如果a和b不相等,那么a.equals(b)一定为false,则a.hashCode()和b.hashCode()尽量不要相等。
上述第一条规范是正确性,必须保证实现,否则HashMap不能正常工作。
而第二条如果尽量满足,则可以保证查询效率,因为不同的对象,如果返回相同的hashCode(),会造成Map内部存储冲突,使存取的效率下降。
正确编写equals()的方法在上面已经说过了,以Person类为例:
public class Person {
String firstName;
String lastName;
int age;
}
把需要比较的字段找出来:
firstName
lastName
age
然后,引用类型使用Objects.equals()比较,基本类型使用==比较。
在正确实现equals()的基础上,我们还需要正确实现hashCode(),即上述3个字段分别相同的实例,hashCode()返回的int必须相同:
ublic class Person {
String firstName;
String lastName;
int age;
@Override
int hashCode() {
int h = 0;
h = 31 * h + firstName.hashCode();
h = 31 * h + lastName.hashCode();
h = 31 * h + age;
return h;
}
}
注意到String类已经正确实现了hashCode()方法,我们在计算Person的hashCode()时,反复使用31*h,这样做的目的是为了尽量把不同的Person实例的hashCode()均匀分布到整个int范围。
和实现equals()方法遇到的问题类似,如果firstName或lastName为null,上述代码工作起来就会抛NullPointerException。为了解决这个问题,我们在计算hashCode()的时候,经常借助Objects.hash()来计算:
int hashCode() {
return Objects.hash(firstName, lastName, age);
}
编写equals()和hashCode()遵循的原则是:
equals()用到的用于比较的每一个字段,都必须在hashCode()中用于计算;equals()中没有使用到的字段,绝不可放在hashCode()中计算。
另外注意,对于放入HashMap的value对象,没有任何要求
Hash表原理
hashCode方法
从hashCode的原理中,我们可以看到,key的hashCode()方法的返回值对HashMap存储元素时会起着很重要的作用。而hashCode()方法实际上是Object中定义的。那么应该妥善重写该方法:
- 对于重写equals方法的对象,一般要妥善的重写继承自Object类的hashCode方法(Object提供的hashCode方法将返回该对象所在内存地址的整数形式)。
- 重写hashCode方法需注意两点:一、与equals方法的一致性,即equals比较返回true的两个对象其hashCode方法返回值应该相同。二、hashCode返回的数值应符合hash算法要求,试想如果有很多对象的hashCode方法返回值都相同,则会大大降低hash表的效率,一般情况下可以使用IDE提供的工具自动生成hashCode方法
重写hashCode方法
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + age;
result = prime * result + ((gender == null) ? 0 : gender.hashCode());
result = prime * result + ((name== null) ? 0 : name.hashCode());
long temp;
Temp = Double.doubleToLongBits(salary);
result = prime * result + (int) (temp ^ (temp >>> 32));
return result;
}
装载因子及HashMap优化
- Capacity:容量,hash表里bucket(桶)的数量,也就是散列表(散列表 ( Hash table ,也叫 哈希表 ))数组的大小。
- Initial capacity:初始容量,创建hash表时,初始bucket的数量,默认构建容量是16,也可以使用特定的容量。
- Size:大小,当前散列表中存储数据的数量。
- Load factor:加载因子,默认值0.75(就是75%),当散列表增加数据时,如果size/capacity的值大于Load factory则发生扩容并且重新散列(rehash)。
- 性能优化:加载因子较小时,散列查找性能会提高,同时也浪费了散列桶空间容量。0.75是性能和空间相对平衡结果。在创建散列表时指定合理容量,减少rehash提高性能。
Map的遍历
Map提供了三种遍历方式:
- 遍历所有的Key
- 遍历所有的Key-Value对
- 遍历所有的Value(不常用)
遍历所有Key的方法:
Set < K > keySet()
该方法会将当前Map中所有的key存入一个Set集合后返回
遍历所有的键值对的方法:
Set <Entry <K,V>> entrySet()
该方法会将当前Map中每一组key-value对封装为一个Entry对象并存入一个Set集合后返回
EnumMap
Java集合库提供的一种EnumMap,它在内部以一个非常紧凑的数组存储value,并且根据enum类型的key直接定位到内部数组的索引,并不需要计算hashCode(),不但效率最高,而且没有额外的空间浪费。
我们以DayOfWeek这个枚举类型为例,为它做一个“翻译”功能:
import java.time.DayOfWeek;
import java.util.*;
public class Main {
public static void main(String[] args) {
Map<DayOfWeek, String> map = new EnumMap<>(DayOfWeek.class);
map.put(DayOfWeek.MONDAY, "星期一");
map.put(DayOfWeek.TUESDAY, "星期二");
map.put(DayOfWeek.WEDNESDAY, "星期三");
map.put(DayOfWeek.THURSDAY, "星期四");
map.put(DayOfWeek.FRIDAY, "星期五");
map.put(DayOfWeek.SATURDAY, "星期六");
map.put(DayOfWeek.SUNDAY, "星期日");
System.out.println(map);
System.out.println(map.get(DayOfWeek.MONDAY));
}
}
//{MONDAY=星期一, TUESDAY=星期二, WEDNESDAY=星期三, THURSDAY=星期四, FRIDAY=星期五, SATURDAY=星期六, SUNDAY=星期日}
//星期一
如果Map的key是enum类型,推荐使用EnumMap,既保证速度,也不浪费空间。
使用EnumMap的时候,根据面向抽象编程的原则,应持有Map接口。
TreeMap
还有一种Map,它在内部会对Key进行排序,这种Map就是SortedMap。注意到SortedMap是接口,它的实现类是TreeMap。
使用TreeMap时,放入的Key必须实现Comparable接口。String、Integer这些类已经实现了Comparable接口,因此可以直接作为Key使用。作为Value的对象则没有任何要求。
Comparable
- Collections的sort方法是对集合元素进行自然排序,那么两个元素之间就一定要有大小之分。这个大小之分是如何定的呢?实际上,在使用Collections的sort排序的集合元素都必须是Comparable接口实现的类,该接口表示其子类是可比较的,因为实现该接口必须重写抽象方法:
int compareTo(T t);
该方法用于使当前对象与给定对象进行比较:
- 若当前对象大于给定对象,那么返回值应为>0的整数
- 若小于给定对象,那么返回值应为<0 的整数
- 若两个对象相等,则应返回0
public void testComparable(){
/*Cell实现了Comparable接口
CompareTo方法逻辑为按照row值得大小排序
*/
//public int compareTo(Cell 0){return this.row - o.row;}
List<Cell> cells = new ArrayList<Cell>();
cells.add(new Cell(2, 3));
cells.add(new Cell(5, 3));
cells.add(new Cell(6, 5));
Collections.sort(cells);
System.out.println(cells);
}
Comparator
- 一旦Java类实现了Comparable接口,其比较逻辑就已经确定;如果希望在排序的操作中临时指定比较规则,可以采用Comparator接口回调的方式。
- Comparator接口要求实现类必须重写其定义方法:
- int compare(T a, T b);
该方法的返回值要求:
- 如果a<b,则返回负数,通常是-1
- 如果a==b,则返回0
- 如果a>b,则返回正数,通常是1
最后来看看一个代码:
import java.util.*;
public class Main {
public static void main(String[] args) {
Map<Student, Integer> map = new TreeMap<>(new Comparator<Student>() {
public int compare(Student p1, Student p2) {
return p1.score > p2.score ? -1 : 1;
}
});
map.put(new Student("Tom", 77), 1);
map.put(new Student("Bob", 66), 2);
map.put(new Student("Lily", 99), 3);
for (Student key : map.keySet()) {
System.out.println(key);
}
System.out.println(map.get(new Student("Bob", 66))); // null?
}
}
class Student {
public String name;
public int score;
Student(String name, int score) {
this.name = name;
this.score = score;
}
public String toString() {
return String.format("{%s: score=%d}", name, score);
}
}
/*结果为:
{Lily: score=99}
{Tom: score=77}
{Bob: score=66}
null
*/
小结:SortedMap在遍历时严格按照Key的顺序遍历,最常用的实现类是TreeMap;
作为SortedMap的Key必须实现Comparable接口,或者传入Comparator;
要严格按照compare()规范实现比较逻辑,否则,TreeMap将不能正常工作。
Queue和Deque
Queue
队列(Queue)是常用的数据结构,可以将队列看成特殊的线性表,队列限制了对线性表的访问方式:只能从线性表的一端添加元素,从另一端取出元素。
队列遵循先进先出(FIFO:First In First Out)的原则。
它和List的区别在于,List可以在任意位置添加和删除元素,而Queue只有两个操作:
- 把元素添加到队列末尾;
- 从队列头部取出元素。
Queue接口中主要方法如下:
int size() | 获取队列长度 |
---|---|
boolean add(E)/boolean offer(E) | 添加元素到队尾; |
E remove()/E poll() | 获取队首元素并从队列中删除; |
E element()/E peek() | 获取队首元素但并不从队列中删除 |
对于具体的实现类,有的Queue有最大队列长度限制,有的Queue没有。注意到添加、删除和获取队列元素总是有两个方法,这是因为在添加或获取元素失败时,这两个方法的行为是不同的。
throw Exception | 返回false或null | |
---|---|---|
添加元素到队尾 | add(E e) | boolean offer(E e) |
取队首元素并删除 | E remove() | E poll() |
取队首元素但不删除 | E element() | E peek() |
举个栗子,假设我们有一个队列,对它做一个添加操作,如果调用add()方法,当添加失败时(可能超过了队列的容量),它会抛出异常:
Queue<String> q = ...
try {
q.add("Apple");
System.out.println("添加成功");
} catch(IllegalStateException e) {
System.out.println("添加失败");
}
如果我们调用offer()方法来添加元素,当添加失败时,它不会抛异常,而是返回false:
Queue<String> q = ...
if (q.offer("Apple")) {
System.out.println("添加成功");
} else {
System.out.println("添加失败");
}
Deque
Queue是队列,只能一头进,另一头出。
如果把条件放松一下,允许两头都进,两头都出,这种队列叫双端队列(Double Ended Queue),学名Deque。
- Deque是Queue的子接口,定义了所谓的“双端队列”,即从队列的两端分别可以入队(offer)和出队(poll),LinkedList实现了该接口。
- 如果将Deque限制为只能从一端入队和出队,则可实现“栈(Stack Overflow)”的数据结构,对于栈而言,入栈称之为push,出栈称之为pop。
- 栈遵循先进后出(FILO First Input Last Output)的原则
我们来比较一下Queue和Deque出队和入队的方法:
Queue | Deque | |
---|---|---|
添加元素到队尾 | add(E e) / offer(E e) | addLast(E e) / offerLast(E e) |
取队首元素并删除 | E remove() / E poll() | E removeFirst() / E pollFirst() |
取队首元素但不删除 | E element() / E peek() | E getFirst() / E peekFirst() |
添加元素到队首 | 无 | addFirst(E e) / offerFirst(E e) |
取队尾元素并删除 | 无 | E removeLast() / E pollLast() |
取队尾元素但不删除 | 无 | E getLast() / E peekLast() |
对于添加元素到队尾的操作,Queue提供了add()/offer()方法,而Deque提供了addLast()/offerLast()方法。添加元素到对首、取队尾元素的操作在Queue中不存在,在Deque中由addFirst()/removeLast()等方法提供。
注意到Deque接口实际上扩展自Queue:
public interface Deque<E> extends Queue<E> {
...
}
因此,Queue提供的add()/offer()方法在Deque中也可以使用,但是,使用Deque,最好不要调用offer(),而是调用offerLast():
import java.util.Deque;
import java.util.LinkedList;
public class Main {
public static void main(String[] args) {
Deque<String> deque = new LinkedList<>();
deque.offerLast("A"); // A
deque.offerLast("B"); // B -> A
deque.offerFirst("C"); // B -> A -> C
System.out.println(deque.pollFirst()); // C, 剩下B -> A
System.out.println(deque.pollLast()); // B
System.out.println(deque.pollFirst()); // A
System.out.println(deque.pollFirst()); // null
}
}
Stack
刚刚再说Deque的时候说了一下栈,现在再来拓展一下
Stack是这样一种数据结构:只能不断地往Stack中压入(push)元素,最后进去的必须最早弹出(pop)来:
Stack只有入栈和出栈的操作:
- 把元素压栈:push(E);
- 把栈顶的元素“弹出”:pop(E);
- 取栈顶元素但不弹出:peek(E)。
在Java中,我们用Deque可以实现Stack的功能:
- 把元素压栈:push(E)/addFirst(E);
- 把栈顶的元素“弹出”:pop(E)/removeFirst();
- 取栈顶元素但不弹出:peek(E)/peekFirst()
Stack的作用
Stack在计算机中使用非常广泛,JVM在处理Java方法调用的时候就会通过栈这种数据结构维护方法调用的层次。例如:
static void main(String[] args) {
foo(123);
}
static String foo(x) {
return "F-" + bar(x + 1);
}
static int bar(int x) {
return x << 2;
}
JVM会创建方法调用栈,每调用一个方法时,先将参数压栈,然后执行对应的方法;当方法返回时,返回值压栈,调用方法通过出栈操作获得方法返回值。
因为方法调用栈有容量限制,嵌套调用过多会造成栈溢出,即引发StackOverflowError:
public class Main {
public static void main(String[] args) {
increase(1);
}
static int increase(int x) {
return increase(x) + 1;
}
}
Collections
Collections是JDK提供的工具类,同样位于java.util包中。它提供了一系列静态方法,能更方便地操作各种集合。
注意Collections结尾多了一个s,不是Collection!
我们一般看方法名和参数就可以确认Collections提供的该方法的功能。例如,对于以下静态方法:
public static boolean addAll(Collection<? super T> c, T... elements) { ... }
创建空集合
Collections提供了一系列方法来创建空集合:
- 创建空List:List emptyList()
- 创建空Map:Map<K, V> emptyMap()
- 创建空Set:Set emptySet()
要注意到返回的空集合是不可变集合,无法向其中添加或删除元素。
此外,也可以用各个集合接口提供的of(T…)方法创建空集合。例如,以下创建空List的两个方法是等价的:
List<String> list1 = List.of();
List<String> list2 = Collections.emptyList();
创建单元素集合
Collections提供了一系列方法来创建一个单元素集合:
- 创建一个元素的List:List singletonList(T o)
- 创建一个元素的Map:Map<K, V> singletonMap(K key, V value)
- 创建一个元素的Set:Set singleton(T o)
要注意到返回的单元素集合也是不可变集合,无法向其中添加或删除元素。
此外,也可以用各个集合接口提供的of(T…)方法创建单元素集合。例如,以下创建单元素List的两个方法是等价的:
List<String> list1 = List.of("apple");
List<String> list2 = Collections.singletonList("apple");
排序
Collections是集合的工具类,其中自然就有用于排序的sort()方法
方法定义为:
void sort(List< T > list)
该方法的作用是对给定的集合元素进行自然排序
public void testSort(){
List<Integer> list = new ArrayList<Integer>();
Random r = new Random(1);
for(int i = 0; i < 10; i++){
list.add(r.nextInt(100));
}
System.out.println(r.nextInt(100));
Collections.sort(list);
System.out.println(list);
}
洗牌
Collections提供了洗牌算法,即传入一个有序的List,可以随机打乱List内部元素的顺序,效果相当于让计算机洗牌:
public class Main {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
for (int i=0; i<10; i++) {
list.add(i);
}
// 洗牌前:
System.out.println(list);
Collections.shuffle(list);
// 洗牌后:
System.out.println(list);
}
}
不可变集合
Collections还提供了一组方法把可变集合封装成不可变集合:
- 封装成不可变List:List unmodifiableList(List<? extends T> list)
- 封装成不可变Set:Set unmodifiableSet(Set<? extends T> set)
- 封装成不可变Map:Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m)
这种封装实际上是通过创建一个代理对象,拦截掉所有修改方法实现的。我们来看看效果:
public class Main {
public static void main(String[] args) {
List<String> mutable = new ArrayList<>();
mutable.add("apple");
mutable.add("pear");
// 变为不可变集合:
List<String> immutable = Collections.unmodifiableList(mutable);
immutable.add("orange"); // UnsupportedOperationException!
}
}
线程安全集合
ollections还提供了一组方法,可以把线程不安全的集合变为线程安全的集合:
- 变为线程安全的List:List synchronizedList(List list)
- 变为线程安全的Set:Set synchronizedSet(Set s)
- 变为线程安全的Map:Map<K,V> synchronizedMap(Map<K,V> m)
写了好久,也看了很多大佬的博客,因为集合算是Java当做的一大块知识了,复习起来也慢,知识点太多,我也不敢说我全部都懂了,肯定还有我没接触到的。接下来的I/O,线程等都是重点难点,所以我不会很快就更新,我得自己搞透彻了才敢放出来。最后祝所有程序猿,节日快乐