文章目录
七、Java集合
Java 集合类是一种工具类,可用于存储数量不等的对象,并可以实现常用的数据结构,如栈、队列等。除此之外,Java 集合还可以保存具有映射关系的关联数组。Java 集合大致可分为 Set、List、Queue 和 Map 四种体系。其中 Set 代表无序、不可重复的集合;List 代表有序、重复的集合;而 Map 则代表具有映射关系的集合;Java 5 中新增的 Queue 集合代表队列集合。
Java 集合就像一个容器,可以把多个对象(实际上是对象的引用)放进容器中,Java 5 之前会丢失容器中所有对象点的数据类型,把所有对象都当成 Object 类型处理;从 Java 5 增加泛型以后,Java 集合可以记住容器中对象的数据类型,从而可以编写出更简洁、健壮的代码。
7.1 Java集合概述
为了保存数据不确定的数据,以及保存具有映射关系的数据(也被称为关联数组),Java 提供了集合类,集合类主要负责保存、承装其他数据,因此集合类也被称为容器类。所有的集合类都位于 java.uti 包下,后来为了处理多线程环境下的并发安全问题,Java 5 还在 java.util.concurrent 包下提供了一些多线程支持的集合类。
集合类和数组不一样,数组元素既可以是基本类型的值,也可以是对象(实际保存的是对象的引用变量);而集合里只能保存对象。Java 集合类中主要由两个接口派生而出:Collection 和 Map,Collection 和 Map 是 Java 集合框架的根接口,这两个接口又包含了一些子接口和实现类。
其中 Set 和 List 接口是 Collection 接口派生的两个子接口,它们分别代表了无序集合和有序集合;Queue 是 Java 提供的队列实现。
所有的 Map 实现类用于保存具有映射关系的数据,即每项数据都是 key-value 对,Map 中的 key 是不可重复的,用于标识集合里的每项数据,如果需要查询 Map 中的数据时,总是根据 Map 的 key 来获取。
Java 集合可以分为三大类:Set 集合类似一个罐子,把一个对象添加到 Set 集合时,Set 集合无法记住添加这个元素的顺序,所以 Set 里的元素不能重复(否则系统无法准确识别);List 集合则类似数组,可以记住每次添加元素的顺序,且 List 的长度可变。Map 集合也像一个罐子,只是里面的每项数据都由两个值组成。常见的实现类有HashSet
、TreeSet
、ArrayList
、ArrayDeque
、LinkedList
和HashMap
、TreeMap
等实现类。
如果访问 List 集合中的元素,可以直接根据元素的索引来访问;入库过访问 Map 集合中的元素,可以根据每项元素的 key 来访问其 value;如果访问 Set 集合中的元素,则只能根据元素本身来访问(这也是 Set 集合里的元素不允许重复的原因)。
7.2 Collection 和 Iterator
Collection 接口是 List、Set 和 Queue 的父接口,该接口内定义的方法可用于操作 Set、LIst 和 Queue 集合。
- boolean add(Object o):该方法用于向集合里添加一个元素。如果集合对象被添加操作改变了,则返回 true;
- boolean addAll(Collection c):该方法把集合 c 里的所有元素添加到指定集合里。如果集合对象被添加操作改变了,则返回 true;(并集)
- void clear():清除集合里的所有元素,将集合长度变为 0;
- boolean contains(object o):返回集合里是否包含指定元素;
- boolean containsAll(Collection c):返回集合里是否包含集合 c 里的所有元素;
- boolean isEmpty():返回集合是否为空,当集合长度为 0 时返回 true,否则返回 false;
- Iterator iterator():返回一个 Iterator 对象,用于遍历集合里的元素;
- boolean remove(Object o):删除集合中指定元素 o,当集合中包含一个或多个元素 o 时,该方法只删除第一个符合条件的元素,该方法将返回 true;
- boolean removeAll(Collection c):从集合中删除集合 c 里包含的所有元素(相当于调用该方法的集合减去集合 c),如果删除了一个或多个元素,则该方法返回 true;(差集)
- boolean retainAll(Collection c):从集合中删除集合 c 里不包含的元素,如果该操作改变了调用该方法的集合,则该方法返回 true;(交集)
- int size():该方法返回集合里元素的个数;
- Object[] toArray:该方法把集合转换成一个数组,所有的集合元素变成对应的数组元素;
public class CollectionTest {
public static void main(String[] args) {
Collection c = new ArrayList();
//添加元素
c.add("Java编程思想");
//虽然集合中不能放基本类型的值,但Java支持自动装箱
c.add(66);
System.out.println("c集合中元素个数为" + c.size());//输出2
c.remove(66);
System.out.println("c集合中元素个数为" + c.size());//输出1
//判断是否包含指定字符串
System.out.println(c.contains("Java编程思想"));//输出true
System.out.println(c);
Collection books = new HashSet();
books.add("Java编程思想");
books.add("Java核心技术卷");
System.out.println("books集合是否包含c集合:" + c.containsAll(c));//输出true
//用books集合减去c集合
books.removeAll(c);
System.out.println(books);
//删除c集合所有元素
c.clear();
System.out.println(c);
//控制books集合中值保留c中也包含的元素
books.retainAll(c);
System.out.println(books);
}
}
上面程序创建了两个 Collection 对象,一个是 c 集合,一个是 books 集合,其中 c 集合是 ArrayList,而 books 集合是 HashSet。虽然实现类不同,但当成 Collection 使用 add、remove 等方法并没有区别。所有的 Collection 实现类都重写了 toString 方法,该方法可以一次性输出集合中的所有元素。
在传统模式下,把一个对象丢进集合中后,集合会忘记这个对象的类型,也就是说系统会把所有的集合元素都当成 Object 类型。在 JDK 1.5 以后,可以使用泛型来限制集合里元素的类型,并让集合记住所有集合元素的类型。
7.2.1 使用Lambda表达式遍历集合
Java 8 为 Iterator 接口新增了一个forEach(Consumer action)
默认方法,该方法所需的类型是一个函数式接口,而 Iterable 接口是 Collection 接口的父接口,因此 Collection 集合也可以直接调用该方法。当程序调用 Iterable 的forEach(Consumer action)
遍历集合元素,程序会依次将集合元素传给 Consumer 的accept(T t)
方法(该接口中的唯一抽象方法)。正因为 Consumer 是函数式接口,因此可以使用 Lambda 接口表达式来遍历集合元素。
Collection book = new HashSet();
book.add("Java");
book.add("JVM");
book.forEach(obj -> System.out.println("迭代集合元素:" + obj));
7.2.2 使用 Java 8 增强的Iterator遍历集合元素
Iterator 接口也是 Java 集合框架的成员,但它与 Collection系列、Map 系列的集合不一样:Collection 系列集合、Map 系列集合主要用于盛装其他对象,而 Iterator 主要用于遍历(即迭代访问)Collection 集合中的元素,Iterator 对象也被称为迭代器。Iterator 接口隐藏了各种 Collection 实现类的底层细节,向应用程序提供了遍历 Collection 集合元素的统一编程接口。
- boolean hasNext():如果被迭代的集合元素还没有被遍历完,则返回 true;
- Object next():返回集合里的下一个元素;
- void remove():删除集合里的上一次 next 方法返回的元素;
- void forEachRemaining(Consumer action):Java 8 新增的默认方法,可以使用 Lambda表达式遍历整个集合;
public class IteratorTest {
public static void main(String[] args) {
Collection books = new HashSet();
books.add("Java");
books.add("JVM");
books.add("JRE");
Iterator it = books.iterator();
while (it.hasNext()) {
//it.next()方法返回的数据类型是Object类型,因此需要强制类型转换
String book = (String) it.next();
System.out.println("直接打印:" + book);
if (book.equals("JVM")) {
//从集合中删除上一次next()方法返回的元素
it.remove();
}
//对剩余(Remaining)的元素进行循环处理(forEach)。
// forEachRemaining方法接受一个Consumer参数表示循环体执行的内容,针对每个元素会执行的方法,所以参数名叫action
//调用完毕后迭代器中不再有下一个元素,退出了while循环
it.forEachRemaining(obj -> System.out.println(obj));
//只能调用一次,第二次将不会有任何输出
it.forEachRemaining(obj -> System.out.println(obj));
//对book变量赋值不会改变集合元素本身
book = "测试字符串";
}
System.out.println(books);
}
}
Iterator 仅用于遍历集合,Iterator 本身并不提供盛装对象的能力,如果需要创建 Iterator 对象,则必须由一个迭代集合。
Iterator 必须依附于 Collection 对象,若有一个 Iterator 对象,则必然有一个与之相关联的 Collection 对象。Iterator 提供了两个方法来迭代访问 Collection 集合里的元素。
当使用 Iterator 迭代访问 Collection 集合元素时,Collection 集合里的元素不能被改变,只有通过 Iterator 的remove()
方法删除上一次next()
方法返回集合元素才可以,否则会引发java.util.ConcurrentModificatonException
异常。Iterantor 迭代器采用的是快速失败 (fail-fast)机制,一旦在迭代过程中检测到该集合已经被修改(通常是被程序中其他线程修改),程序立即引发异常,而不是显示修改后的结果,这样可以避免共享资源而引发的潜在问题。
7.2.3 使用foreach循环遍历集合元素
foreach 循环迭代访问 Collection 集合里的元素更加简洁,其内部实现原理也是通过 Iterator 实现的,只不过是 Java 编译器自动完成的,但因为每次需要做类型转换检查,因此花费时间比 Iterator 略长,但时间复杂度与 Iterator 一样。foeach 循环中的迭代变量也不是集合元素本身,系统只是依次把集合元素的值赋给迭代变量,因此在 foreach 循环中修改迭代变量的值没有任何实际意义。同样,在使用 foreach 循环中修改集合元素会引发java.util.ConcurrentModificatonException
异常。
for(Object obj: books)
{
//此处obj变量也不是集合元素本身
System.out.println((String)obj);
}
7.2.4 使用 Java 8 新增的Predicate集合
Java 8 为 Collection 集合新增了一个removeIf(Predicate filter)
方法,该方法将会批量删除符合 filter 条件的所有元素,该方法性需要一个 Predicate (谓词) 对象作为参数,Predicate 也是函数式接口,因此可以使用 Lambda 表达式作为参数。
import java.util.Collection;
import java.util.HashSet;
import java.util.function.Predicate;
public class PredicateTest {
public static void main(String[] args) {
Collection books = new HashSet();
books.add("Java编程思想");
books.add("Java核心技术");
books.add("Java虚拟机");
//统计书名包含Java的图书数量
System.out.println(calAll(books, book -> ((String) book).contains("Java")));//输出3
//统计书名包含编程的图书数量
System.out.println(calAll(books, book -> ((String)book).contains("编程")));//输出1
//统计书名字符大于7的图书数量
System.out.println(calAll(books, book -> ((String)book).length() > 7));//输出2
}
public static int calAll(Collection books, Predicate p) {
int total = 0;
for (Object obj : books) {
//使用Predicate的test()方法判断该对象是否满足Predicate指定的条件
if (p.test(obj)) {
total++;
}
}
return total;
}
}
程序定义一个 calAll() 方法,该方法会使用 Predicate 判断每个集合元素是否符合特定条件,该条件通过 Predicate 参数动态传入。
7.2.5 使用 Java 8 新增的Stream操作集合
Java 8 新增了 Stream、IntStream、LongStream、DoubleStream 等流式 API,这些 API 代表多个支持串行和并行聚集操作的元素。其中 Stream 是一个通用的流接口,而 IntStream、LongStream、DoubleStream 则代表 int、long、double 的流。此外,Java 8 还为每个流式 API 提供了对应的 Builder,例如 Stream.Builder、IntStream.Builder、LongStream.Builder、DoubleStream.builder,开发者可以通过这些 Builder 来创建对应的流,但对于大部分聚集函数而言,每个 Stream 只能执行一次。
独立使用 Stream 的步骤如下:
- 使用 Stream 或 XxxStream 的
builder()
类方法创建该 Stream 对应的 Builder; - 重复调用 Builder 的
add()
方法向流中添加多个元素; - 调用 Builder 的
build()
方法获取对应的 Stream; - 调用 Stream 的聚集方法;
import java.util.stream.IntStream;
public class IntStreamTest {
public static void main(String[] args) {
IntStream is = IntStream.builder().add(25).add(14).add(-1).build();
//下面聚集函数的代码每次只能执行一次,其他的需要注释掉
System.out.println("最大值:" + is.max().getAsInt());
System.out.println("最小值:" + is.min().getAsInt());
System.out.println("总和:" + is.sum());
System.out.println("个数:" + is.count());
System.out.println("平均值:" + is.average());
System.out.println("是否都大于20" + is.allMatch(ele -> ele*ele > 20));
System.out.println("是否有大于20" + is.anyMatch(ele -> ele*ele > 20));
//将is映射成一个新的Stream,新Stream的每个元素是原Stream元素的2倍加1
IntStream newIs = is.map(ele -> ele*2 + 1);
//使用方法引用的方式来遍历集合元素
newIs.forEach(System.out :: println);
}
}
Stream 提供了大量的方法进行聚集操作,这些方法既可以是中间的(intermediate),也可以是末端的(terminal)。
- 中间方法:中间操作允许流保持打开状态,并允许直接调用后续方法,中间方法的返回值是另一个流;
- 末端方法:末端方法是对流的最终操作,当对某个 Stream 执行末端方法后,该流将会被“消耗掉”不再可用;
除此之外,关于流的方法还有以下两个特征:1. 有状态的方法:这种方法会给流增加一些新的属性,比如元素的唯一性、元素的最大舒亮亮、保证元素以排序的方式被处理等,有状态的方法往往需要更大的性能开销;2. 短路方法:短路方法可以尽早结束对流的操作,不必检查所有的元素。
Stream 常用的中间方法:
- filter(Predicate predicate):过滤 Stream 中所有不符合 Predicate 的元素;
- mapToXxx(ToXxxFunction mapper):使用 ToXxxFunction 对流中的元素执行一对一的转换,该方法返回的新流中包含了 ToXxxFunction 转换生成的所有元素;
- peek(Consumer action):依次对每个元素执行一些操作,该方法返回的流与原有流包含相同的元素,该方法主要用于调试;
- distinct():该方法用于排序流中所有的重复元素(判断元素重复的标准是使用 equals() 比较返回 true);
- sorted():该方法用于保证流中的元素在后续的访问中处于有序状态,这是一个有状态的方法;
- limit(long maxSize):该方法用于保证对该流的后续访问中最大允许访问的元素个数,这是一个与状态、短路的方法;
Stream 常用的末端方法: - forEach(Consumer action):遍历流中所有元素,对每个元素执行 action;
- toArray():将流中所有元素转换成数组;
- reduce():该方法有三个重载版本,都用于通过某种操作来合并流中的元素
- min():返回流中所有元素的最小值;
- max():返回流中所有元素的最大值;
- count():返回流中所有元素的数量;
- anyMatch(Predicate predicate):判断流中手至少包含一个元素符合 Predicate 条件;
- allMatch(Predicate predicate):判断流中是否每个元素都符合 Predicate 条件;
- noneMatch(Predicate predicate):判断流中是否所有元素都不符合 Predicate 条件;
- findFirst():返回流中第一个元素;
- findAny():返回流中任意一个元素;
除此之外,Collection 接口提供了一个 Stream() 默认方法,该方法可以返回该集合对应的流,接下来可以通过流式 API 来操作集合元素。由于 Stream 可以对集合元素进行整体的聚集操作,因此 Stream 极大的丰富了集合的功能。
public class CollectionStream {
public static void main(String[] args) {
Collection books = new HashSet();
books.add("Java编程思想");
books.add("Java核心技术");
books.add("Java虚拟机");
System.out.println(books.stream().filter(ele -> ((String)ele).contains("Java")).count());
System.out.println(books.stream().filter(ele -> ((String)ele).contains("编程")).count());
System.out.println(books.stream().filter(ele->((String)ele).length() > 7).count());
}
}
程序只要调用 Collection 的 stream() 方法即可返回该集合对应的 Stream,可以看出,Stream 提供的方法对所有集合元素进行处理极大简化了集合编程的代码。
7.3 Set 集合
Set 集合类似于一个罐子,通常不能记住元素的添加顺序,Set 集合和 Collection 集合基本相同,没有提供任何额外的方法,实际上 Set 就是 Collection,只是行为略有不同(Set 不能包含重复元素)。如果试图把两个相同元素加入同一个 Set 集合中,则添加操作失败,add() 方法返回 false,且新元素不会被加入。
7.3.1 HashSet 类
HashSet 类是 Set 接口的典型实现,大多数时候使用 Set 集合时就是使用这个实现类,HashSet 按 Hash 算法来存储集合中的元素,因此具有很好的存取和查找性能。HashSet 具有以下特点:
- 不能保证元素的排列顺序,顺序可能与添加顺序不同,顺序也有可能发生变化;
- HashSet 不是同步的,如果多个线程同时访问一个 HashSet,假设有两个或两个以上线程同时修改了 HashSet 集合时,则必须通过代码来保证其同步;
- 集合元素值可以是 null;
当向 HashSet 集合中存入一个元素时,HashSet 会调用该对象的 hashcode() 方法来得到该对象的 hashcode 值,然后根据该 hashcode 值决定该对象在 HashSet 中的存储位置。如果有两个元素通过 equals() 方法比较返回 true,但他们的 hashcode() 方法返回值不等,HashSet 将会把他们存放在不同的位置,依然可以添加成功。也就是说,HashSet 集合判断两个元素相等的标准是两个对象通过 equals() 方法比较相等,并且两个对象的 hashcode() 方法返回值相等。
当把一个对象放入 HashSet 中时,如果需要重写该对象对应类的 equals() 方法,则也应该重写其 hashcode() 方法。尽量保证两个对象通过 equals() 方法比较返回 true 时,
- 如果两个对象通过 equals() 方法比较返回 true,但这两个对象的 hashcode() 方法返回不同的 hashcode 值,这将导致 HashSet 会把这两个对象保存在 Hash 表的不同位置,从而使两个对象都可以添加成功,这就与 Set 集合的规则冲突了。
- 如果两个对象的 hashcode() 方法返回的 hashcode 值相同,但它们通过 equals() 方法比较返回 false 时更麻烦:因为两个对象的 hashcode 值相同,HashSet 将试图把它们保存在同一个位置,但又不行(否则将只剩下一个对象),所以实际上会在这个位置用链式结构来保存多个对象;而 HashSet 访问集合元素时也是根据元素的 hashcode 值来快速定位的,如果 HashSet 中两个以上的元素具有相同的 hashcode 值,将会导致性能下降。
HashSet 中每个能存储元素的“槽位”(slot)被称为“桶”(bucket),如果有多个元素的 hashcode 值相同,但它们通过equals()
方法比较返回 false,就需要在一个“桶”里放入多个元素,这样会导致性能下降。因此重写hashcode()
方法的基本规则为:
- 在程序运行过程中,同一个对象多次调用
hashcode()
方法应该返回相同的值; - 当两个对象通过
equals()
方法比较返回 true 时,这两个对象的hashcode()
方法应该返回相等的值; - 对象中用作
equals()
方法比较标准的实例变量,都应该用于计算 hashcode 值;
重写 hashcode() 方法的一般步骤:
- 把对象每个有意义的实例变量(即每个参与 equals() 方法比较标准的实例变量)计算出一个 int 类型的 hashcode 值;
实例变量类型 | 计算方式 | 实例变量类型 | 计算方式 |
---|---|---|---|
boolean | hashcode = (f ? 0 : 1); | float | hashcode = Float.floatToIntBits(f); |
整数类型(byte、short、char、int) | hashcode = (int )f; | double | long l = Double.doubleToBits(f) ; hashcode = (int)(l^(l >>> 32)); |
long | hashcode = (int)f^(f >>> 32); | 引用类型 | hashcode = f.hashcode(); |
- 用第一步计算出来的 hashcode 值组合计算出一个 hashcode 值返回,为避免直接相加偶然相等,可以通过为各实例变量的
hashcode()
值乘以任意一个质数后再相加;
return f1.hashcode()*19 + (int)f2*31;
String 类重写 hashcode() 方法:重写方法使用 31 作为乘子,31 是一个不大不小的质数,是作为 hashCode 乘子的优选质数之一,不会轻易地溢出 int 类型范围, 而且 31 可以被 JVM 优化,31 * i = (i << 5) - i。
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
如果向 HashSet 中添加一个可变对象后,后面程序修改了该可变对象的实例变量,则可能导致它与集合中的其他元素相同(即两个对象通过equals()
方法比较返回 true,两个对象的 hashcode 值也相等),这就有可能导致 HashSet 中包含两个相同的对象。
import java.util.HashSet;
import java.util.Iterator;
class R {
int count;
public R(int count) {
this.count = count;
}
@Override
public String toString() {
return "R[count: " + this.count + "]";
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj != null && obj.getClass() == R.class) {
R r = (R) obj;
return this.count == r.count;
}
return false;
}
@Override
public int hashCode() {
return this.count;
}
}
public class HashSetTest {
public static void main(String[] args) {
HashSet hs = new HashSet();
hs.add(new R(5));
hs.add(new R(-3));
hs.add(new R(9));
hs.add(new R(-2));
System.out.println(hs);
//取出第一个元素
Iterator it = hs.iterator();
R first = (R) it.next();
//为第一个元素的count实例变量赋值
first.count = -3;
//再次输出 HashSet集合,集合元素有重复元素
System.out.println(hs);
//删除count为-3的R对象
hs.remove(new R(-3));
//删除了一个R元素
System.out.println(hs);
System.out.println(hs.contains(new R(-3)));//输出false
System.out.println(hs.contains(new R(-2)));//输出false
}
}
[R[count: -2], R[count: -3], R[count: 5], R[count: 9]]
[R[count: -3], R[count: -3], R[count: 5], R[count: 9]]
[R[count: -3], R[count: 5], R[count: 9]]
false
false
上面程序提供了 R 类,R 类重写了equals(Object obj)
和hashcode()
方法,当改动实例变量导致集合中两个对象相同时,此时 HashSet 会比较混乱:当试图删除 count 为 -3 的 R 对象时,HashSet 会计算出该对象的 hashcode 值,如果相等则删除该对象,HashSet 只要第二个元素符合该条件(第一个元素实际保存在 count 为 -2 的 R 对象的位置),所以第二个元素被删除,至于第一个 count 为 -3 的 R 对象,它保存在 count 为 -2 的 R 对象的位置,但使用 equals() 方法拿它和 count 为 -2 的 R 对象比较时又返回 false,这将导致 HashSet 不可能准确访问该元素。由此可见,当程序把可变对象添加到 HashSet 中之后,尽量不要去修改该集合元素中参与计算 hashcode()、equals() 的实例变量,否则会导致 HashSet 无法正确操作这个集合元素。
7.3.2 LinkedHashSet
HashSet 还有一个子类 LinkedHashSet,LinkedHashSet 集合也是根据元素的 hashcode 值来决定元素的存储位置的,但它同时使用链表维护元素的次序,这样使得元素看起来是以插入的顺序保存的。也就是说,当遍历 LinkedHashSet 集合里的元素时 LinkedHashSet 将会按照元素的添加顺序来访问集合里的元素。
LinkedHashSet 需要维护元素的插入顺序,因此性能略低于 HashSet 的性能,但在迭代访问 Set 里的全部元素将有很好的性能,因为它以链表来维护内部顺序。
import java.util.LinkedHashSet;
public class LinkedHashSetTest {
public static void main(String[] args) {
LinkedHashSet books = new LinkedHashSet();
books.add("Java编程思想");
books.add("Java核心技术卷");
books.add("Java虚拟机");
System.out.println(books);
books.remove("Java编程思想");
books.add("Java编程思想");
System.out.println(books);
}
}
虽然 LinkedHashSet 使用链表记录集合元素的添加顺序,但 LinkedHashSet 依然是 HashSet,不允许集合元素重复。
7.3.3 TreeSet 类
TreeSet 是 SortedSet 接口的实现类,TreeSet 可以确保集合元素处于排序状态。与 HashSet 相比,TreeSet 还提供了如下几个额外的方法:
- Comparator comparator():如果 TreeSet 采用了定制排序,则该方法返回定制排序所使用的 comparator;如果 TreeSet 采用了自然排序,则返回 null;
- Object first():返回集合中第一个元素;
- Object last():返回集合中最后一个元素;
- Object lower(Object e):返回集合元素中位于指定元素之前的元素(即小于指定元素的最大元素,参考元素不需要是 TreeSet 集合里的元素);
- Object higher(Object e):返回集合元素中位于指定元素之后的元素(即大于指定元素的最小元素,参考元素不需要是 TreeSet 集合里的元素);
- SortedSet subSet(Object fromElement, Object toElement):返回此 Set 的子集合,范围从 fromElement(包含)到 toElement(不包含);
- SortedSet headSet(Object toElement):返回此 Set 的子集,由小于 toElement 的元素组成;
- SortedSet tailSet(Object fromElement):返回此 Set 的子集,由大于或等于 fromElement 的元素组成;
import java.util.TreeSet;
public class TreeSetTest {
public static void main(String[] args) {
TreeSet ts = new TreeSet();
ts.add(5);
ts.add(3);
ts.add(10);
ts.add(-9);
//输出集合中的元素,集合元素已经处于排序状态
System.out.println(ts);//输出[-9, 3, 5, 10]
//输出集合中第一个元素
System.out.println(ts.first());//输出-9
//输出集合中最后一个元素
System.out.println(ts.last());//输出10
//返回集合中小于4的子集,不包含4
System.out.println(ts.lower(4));//输出3
//返回集合中大于等于5的子集
System.out.println(ts.higher(5));//输出5
//返回大于等于-3,小于4的子集
System.out.println(ts.subSet(-3, 4));//输出[3]
}
}
可以看出,TreeSet 并不是根据元素的插入顺序进行排序的,而是根据元素实际值的大小来进行排序的。与 HashSet 集合采用 hash 算法来决定元素的存储位置不同,TreeSet采用红黑树的数据结构来存储集合元素。TreeSet 支持两种排序方法:自然排序和定制排序,在默认情况下,TreeSet 采用自然排序。
1) 自然排序
TreeSet 会调用集合元素的compareTo(Object obj)
方法来比较元素之间的大小关系,然后将集合元素按升序排列,这种方式就是自然排序。Java 提供了一个 comparable 接口,该接口里定义了一个comparaTo(Object obj)
方法,该方法返回一个整数值,实现该接口的类必须实现该方法,实现该接口的类就可以比较大小。当一个对象调用该方法与另一个对象进行比较时,例如obj1.copareTo(obj2)
如果方法返回 0,则表明这两个对象相等;如果该方法返回一个正整数,则表明 obj1 大于 obj2;如果该方法返回一个负整数,则表明 obj1 小于 obj2。
Java 的一些常用类已经实现了 Comparable 接口,并提供了比较大小的标准。下面是实现了 omparable 接口的常用类。
- BigDecimal、BIgInteger 以及所有的数值类型对应的包装类:按他们对象的数值大小进行比较;
- Character:按字符的 UNICODE 值进行比较;
- Boolean:ture 对应的包装类实例大于 false 对应的包装类实例;
- String:按字符串中字符的 UNICODE 值进行比较;
- Date、Time:后面的时间日期比前面的时间日期大;
如果试图把一个对象添加到 TreeSet 时,则该对象的类必须实现 Comparator 接口,否则程序将会抛出异常。
向 TreeSet 集合中添加元素时,只有第一个元素无须实现 Comparable 接口,因为 TreeSet 中没有任何元素,不需要进行比较;但后面的所有元素都必须实现 Comparable 接口,从而调用该对象的 compareTo(Object obj) 方法与集合中其他元素进行比较,否则会引发 ClassCastException 异常。当然这也不是一种好做法,当试图从 TreeSet 中取出元素时,依然会引发 ClassCastException 异常。
注意: 大部分类在实现compareTo(Object obj)
方法时,都需要将被比较对象 obj 强制类型转换成相同类型,因为只有相同类的两个实例才会比较大小。当试图把一个对象添加到 TreeSet 集合时,TreeSet 会调用该对象的compareTo(Object object)
方法与集合中其他元素进行比较,这就要求集合中其他元素与该元素是同一个类的实例,向 TreeSet 中添加的应该是同一个类的对象,否则也会引发 ClassCastException 异常。如果向 TreeSet 中添加的对象是程序员自定义类的对象,则可以向 TreeSet 中添加多种类型的对象,前提是用户自定义类实现了 Comparable 接口,且实现compareTo(Object obj)
方法没有进行强制类型转换,但试图取出 TreeSet 里的集合元素时,不同类型的元素依然会发生 ClassCastException 异常。
总结:如果希望 TreeSet 能正常运作,TreeSet 只能添加同一种类型的对象。
当把一个对象加入 TreeSet 集合中时,TreeSet 会调用该对象的 compareTo(Object obj) 方法与容器中的其他对象比较大小,然后根据红黑树结构找到它的存储位置。如果两个对象通过 compareTo(Object obj) 方法比较相等,新对象将无法添加到 TreeSet 集合中。
TreeSet 集合判断两个对象是否相等唯一标准是:两个对象通过 compareTo(Object obj) 方法比较是否返回 0,如果通过 compareTo(Object obj) 方法比较返回 0,TreeSet 则会认为它们相等;否则不等。
import java.util.TreeSet;
class Z implements Comparable {
int age;
public Z(int age) {
this.age = age;
}
//重写equals方法,总是返回true
@Override
public boolean equals(Object o) {
return true;
}
@Override
public int compareTo(Object o) {
return 1;
}
}
public class TreeSetTest2 {
public static void main(String[] args) {
TreeSet ts = new TreeSet();
Z z = new Z(12);
ts.add(z);
//第二次添加同一个对象,输出true,表明添加成功
System.out.println(ts.add(z));
System.out.println(ts);
//修改Set集合的第一个元素的age变量
((Z)ts.first()).age = 9;
//输出最后一个元素的age变量,也变成9
System.out.println(((Z)ts.last()).age);
//删除元素也是根据 compareTo 方法比较,因此remove(z)无法删除ts中元素
}
}
z 对象的equals()
方法总是返回 true,但 z 对象的compareTo(Object obj)
方法总是返回 1,TreeSet 会认为 z 对象和它自己也不相等,因此 TreeSet 可以同时添加两个对象。
可以看到 TreeSet 对象中保存的两个元素(集合里的元素总是引用,但习惯上把引用对象成为集合元素),实际上是同一个元素,所以修改集合对象中第一个元素 age 变量后,最后一个元素的 age 变量也随之改变。因此,当需要把一个对象放入 TreeSet 中,重写该对象对应类的equals() 方法时,应该保证该方法与 compareTo(Object obj) 方法有一致的结果。
- 如果两个对象通过 euqals() 方法比较返回 false 将很麻烦,因为两个对象通过
compareTo(Object obj)
方法比较相等,TreeSet 不会让第二个元素添加进去,这会与 Set 集合的规则产生冲突。 - 如果向 TreeSet 中添加一个可变对象后,并且后面程序修改了该可变对象的实例变量,这将导致它与其他对象的大小顺序发生改变,但 TreeSet 不会调整它们的顺序,甚至可能导致 TreeSet 中保存的这两个对象通过
compareTo(Object obj)
方法比较返回 0。
import java.util.TreeSet;
class E implements Comparable {
int count;
public E(int count) {
this.count = count;
}
@Override
public String toString() {
return "E[count: " + count + "]";
}
//重写equals()方法,根据count来判断是否相等
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj != null && obj.getClass() == E.class) {
E e = (E) obj;
return e.count == this.count;
}
return false;
}
//重写compareTo()方法,根据count来比较大小
@Override
public int compareTo(Object o) {
E e = (E) o;
return count > e.count ? 1 : count < e.count ? -1 : 0;
}
}
public class TreeSetTest3 {
public static void main(String[] args) {
TreeSet ts = new TreeSet();
ts.add(new E(5));
ts.add(new E(-3));
ts.add(new E(9));
ts.add(new E(-2));
System.out.println(ts);
E first = (E) ts.first();
//给第一个元素count赋值
first.count = 20;
//取出最后一个元素
E last = (E) ts.last();
//对最后一个元素的count赋值,与第二个元素的count相同
last.count = -2;
//再次输出将看到TreeSet里的元素处于无序状态,且重复元素
System.out.println(ts);
//删除实例变量被改变的元素,删除失败
System.out.println(ts.remove(new E(-2)));
System.out.println(ts);
//删除实例变量没有被改变的元素,删除成功
System.out.println(ts.remove(new E(5)));
System.out.println(ts);
//执行上一个删除是会对元素进行重新索引,此时可以删除所有元素
System.out.println(ts.remove(new E(-2)));
System.out.println(ts);
}
}
E 对象对应的类正常重写了equals()
方法和compareTo()
方法,这两个方法都以 R 对象的 count 实例变量作为判断的依据。因为 R 变量是一个可变类,因此可以改变 R 对象的 count 实例变量的值,修改后集合可能处于无序状态或包含重复元素。一旦改变了 TreeSet 集合里可变元素的实例变量,当再试图删除该对象时,TreeSet 也会删除失败(甚至集合中原有的、实例变量没被修改但与修改后元素相等的元素也无法删除),但TreeSet 可以删除没有被修改实例变量、且不与其他被修改实例变量的对象重复的对象。
删除未被修改的实例变量时,TreeSet 会对集合中的元素重新索引(不是重新排序),接下来可以删除 TreeSet 中的所有元素,包括被修改过的实例变量。与 HashSet 类似,如果 TreeSet 中包含了可变对象,当可变对象的实例变量被修改时,TreeSet 在处理这些对象时将非常复杂且容易出错,推荐不要修改放入 HashSet 和 TreeSet 集合中元素的实例变量。
2) 定制排序
TreeSet 的自然排序是根据集合元素的大小,TreeSet 将它们以升序排列,如果需要实现定制排序,则可以通过 Comparator 接口的帮助。该接口中包含一个int compare(T o1, T o2)
方法,该方法用于比较 o1 和 o2 的大小:如果该方法返回正整数,则表明 o1 大于 o2;如果该方法返回 0,则相等;如果该方法返回负整数,则表明 o1 小于 o2。
如果需要实现定制排序,则需要在创建 TreeSet 集合对象时提供一个 Comparator 对象与该 TreeSet 集合相关联,由该 Comparator 对象负责集合的排序逻辑。由于 Comparator 是一个函数式接口,因此可以使用 Lambda 表达式来代替 Comparator 对象。
import java.util.TreeSet;
class M {
int age;
public M(int age) {
this.age = age;
}
@Override
public String toString() {
return "M[" + age + "]";
}
}
public class TreeSetTest4 {
public static void main(String[] args) {
TreeSet ts = new TreeSet((o1, o2) -> {
M m1 = (M) o1;
M m2 = (M) o2;
//根据M对象的age属性来决定大小,age越大,M对象反而越小
return m1.age > m2.age ? -1 : m1.age < m2.age ? 1 : 0;
});
ts.add(new M(5));
ts.add(new M(-3));
ts.add(new M(9));
System.out.println(ts);
}
}
程序使用了目标类型为 Comparator的 Lambda 表达式,负责 ts 集合的排序,所以当 M 对象添加到 ts 集合中时,无需 M 类实现 Comparable 接口,因为此时 TreeSet 无需通过 M 对象本身来比价大小,而是由与 TreeSet 关联的 Lambda 表达式来负责集合元素的排序。
通过 Comparator 对象(或 Lambda表达式)来实现 TreeSet 的定制排序时,依然不可以向 TreeSet 中添加不同类型的对象,否则会引发 ClassCastException 异常。