有 List 了为什么还要有 Set?Java 容器 Set 的核心通关指南

Java 的 Set 接口 java.util.Set 表示一组唯一的对象,换句话说,同一个对象在 Set 中不能多次出现。 Set 接口是 java.util.Collection 接口的子类型,即 Set 继承自Collection。

可以将任何 Java 对象添加到 Java Set。如果 Set 没有使用 Java 泛型进行类型化,那么甚至可以在同一个 Set 中混合不同类型的对象,不过通常情况下我们不会这么使用 Set。

这篇文章我们详细地把 Set 在 Java 中的应用梳理一下,文章大纲如下:

有 List 了为什么还要有 Set?Java 容器 Set 的核心通关指南_第1张图片

Set 和 List 的区别

Set 和 List 接口彼此非常相似。两个接口都表示一个元素的集合。但是,存在一些显著差异。这些差异反映在 Set 和 List 接口包含的方法中。 Set 和 List 接口之间的第一个区别是,相同的元素在 Set 中不能出现多次,List 则是可以有重复元素。Set 和 List 接口之间的第二个区别是,Set 中的元素没有可保证的内部顺序。 List 中的元素具有内部顺序,并且元素可以按该顺序进行迭代。

初识 Set

首先来一个简单示例,让你了解 Set 是怎么工作的:

package com.example.learnset;

import java.util.HashSet;

public class SetExample {

    public static void main(String[] args) {

        Set setA = new HashSet();

        String element = "demo element";

        setA.add(element);

        System.out.println( setA.contains(element) );
    }
}
复制代码

此示例创建一个 HashSet,它是 Java 提供的 Set 接口实现类,紧接着向 Set 中添加一个字符串对象,最后检查 Set 是否包含刚刚添加的元素。

Java 提供的Set实现类

作为 Collection 的子类型,Collection 接口中的所有方法在 Set 接口中也包含。 由于 Set 是一个接口,需要实例化该接口的具体实现才能使用。不过 Java 的集合框架中的已经提供了一些非常优秀的实现,我们可以在以下 Set 实现中进行选择使用:

  • java.util.HashSet
  • java.util.LinkedHashSet
  • java.util.TreeSet
  • java.util.EnumSet

这些 Set 实现中的每一个在迭代 Set 时的元素顺序以及插入和访问 Set 中的元素的时间复杂度都略有不同。

HashSet 底层由 HashMap 实现。当对 HashSet 进行迭代时,它不保证元素的顺序。

LinkedHashSet 与 HashSet 的不同之处在于,它保证了迭代期间元素的顺序与它们插入 LinkedHashSet 的顺序相同。重新插入一个已经在 LinkedHashSet 中的元素不会改变这个顺序。

TreeSet 也保证了迭代时元素的顺序,但元素的排序顺序由它们的自然顺序(如果元素实现了Comparable接口)或由特定的 Comparator 实现确定。

创建 Set 实例

以下是如何创建 Set 实例的几个示例:


import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.TreeSet;

public class SetExample {

    public static void main(String[] args) {

        Set setA = new HashSet();
        Set setB = new LinkedHashSet();
        Set setC = new TreeSet();

    }
}
复制代码

默认情况下,可以将任何对象放入 Set,但是从 Java 5 开始,Java 引入泛型后让限制可插入到 Set 中的对象类型成为可能。下面是一个例子:

Set set = new HashSet();
复制代码

现在只能将 MyObject 实例插入这个 Set 中。然后,无需进行强制类型转换就可以访问和迭代其元素。

for(MyObject anObject : set){
   //do someting to anObject...
}
复制代码

除非有充分的理由不对 Set 使用泛型约束,否则创建 Set 实例时应始终使用泛型约束。

不可变的 Set

下面是一个与我国行情不符(永远 Java 8)的功能点,虽然用不上,也先学一下。

从 Java 9 开始,Set 接口包含一个静态工厂方法,可以创建不可修改的 Set 实例。Set 静态工厂方法 of() 接收零个或多个参数创建不可变的 Set。

Set set = Set.of(); // 创建一个空的,不可变的Set

Set emptyStringSet = Set.of();    

Set immutableSet =  Set.of("val1", "val2", "val3");

复制代码

添加元素到 Set

添加单个元素

要向 Set 添加元素,需要调用其 add() 方法。该方法继承自 Collection 接口。下面一些例子:

Set setA = new HashSet<>();

setA.add("element 1");
setA.add("element 2");
setA.add("element 3");
复制代码

求两个 Set 的并集

与 List 接口一样,Set 也有一个名为 addAll() 的方法,将另一个集合对象中的所有元素添加到 Set 中。

Set set = new HashSet<>();
set.add("one");
set.add("two");
set.add("three");

Set set2 = new HashSet<>();
set2.add("four");

set2.addAll(set);
复制代码

执行此代码示例后,set2 将包含四个字符串元素 "one", "two", "three", "four"。

从 Set 中删除元素

从 Set 中删除指定元素

可以通过调用 remove(Object o) 方法从 Set 中删除指定元素(如果有):

set.remove("object-to-remove");
复制代码

List 接口中还提供了根据索引删除元素的 remove 方法,但是无法根据 Set 中的索引删除对象,因为元素的顺序取决于 Set 具体的实现。

清空 Set

可以使用 clear() 方法从 Set 中删除所有元素:

set.clear();
复制代码

删除在另外一个集合中也存在的元素

Set 的 removeAll(Collection c) 方法,它删除 Set 中的所有也存在于另一个 Collection 中的元素。在集合论中,这被称为求该 Set 与其他集合的差集。下面是一个从 Set 中删除同样也存在于另一个 Set 中的元素的示例:

Set set = new HashSet<>();
set.add("one");
set.add("two");
set.add("three");

Set set2 = new HashSet();
set2.add("three");

set.removeAll(set2);
复制代码

运行例程,set 中包含字符串元素 "one" 和 "two"。元素 "three" 被删除,因为它也存在于 set2 中。

求两个 Set 的交集

跟 List 一样 Set 也有 RetainAll 方法,继承自 Collection 接口,该方法保留 Set 中的所有元素,这些元素也存在于另一个 Collection 中。存在与 Set 中但在其他 Collection 中不存在的所有元素都将被删除。在集合论中,这被称为两个 Set 之间的交集。

Set set = new HashSet<>();
set.add("one");
set.add("two");
set.add("three");

Set set2 = new HashSet<>();
set2.add("three");
set2.add("four");

set.retainAll(set2);
复制代码

运行这段 Java 代码后,该 Set 将只包含字符串元素 "three"。它是 set 和 set2 都存在的元素。

获取 Set 的尺寸

使用 size() 方法检查 Set 的大小。 Set 的大小是 Set 中包含的元素的数量。

et set = new HashSet<>();

set.add("123");
set.add("456");
set.add("789");

int size = set.size(); // size = 3
复制代码

检查 Set 是否为空

通过调用 Set 上的 isEmpty() 方法来检查 Java Set 是否为空,为空意味着 Set 不包含任何元素。

Set set = new HashSet<>();

boolean isEmpty = set.isEmpty();
复制代码

检查 Set 是否包含指定元素

可以通过调用 contains() 方法检查 Java Set 是否包含给定元素(对象):

Set set = new HashSet<>();

set.add("123");
set.add("456");

boolean contains123 = set.contains("123");
复制代码

为了确定 Set 是否包含元素,contains 方法将在内部迭代 Set 元素并将每个元素与作为参数传递进来的对象进行比较。比较使用元素的Java equals 方法来检查元素是否等于参数。 由于可以向 Set 添加空值,因此也可以检查 Set 是否包含空值。以下是检查 Set 是否包含空值的方法:

set.add(null);

containsElement = set.contains(null);

System.out.println(containsElement);
复制代码

显然,如果 contains() 的输入参数为 null,则 contains() 方法不会使用 equals() 方法来比较每个元素,而是使用 == 运算符进行比较。

把 Set 转成 List

可以通过创建 List 并调用其 addAll() 方法,将 Set 作为参数传递给 addAll() 方法,这样能将 Set 转换为 List。下面是一个将 Set 转换为 List 的示例:

Set set = new HashSet<>();
set.add("123");
set.add("456");

List list = new ArrayList<>();
list.addAll(set);
复制代码

迭代 Set

有两种方法可以迭代 Set 的元素:

  • 从 Set 获取 Iterator 进行迭代
  • 使用 for-each 循环进行迭代

如文章开头实现类介绍部分所述,当迭代 Set 中的元素时,元素的顺序取决于使用的 Set 的具体实现。

使用 Iterator 迭代

跟 List 一样,可以通过调用 iterator() 方法从 Set 中获取 Iterator。

Set setA = new HashSet<>();

setA.add("element 1");
setA.add("element 2");
setA.add("element 3");

Iterator iterator = set.iterator();

while(iterator.hasNext(){
  String element = iterator.next();
}
复制代码

使用 for each 循环迭代

Set 接口实现了 Java 的 Iterable 接口,这就是为什么可以使用 for-each 循环迭代 Set 的元素。

Set set = new HashSet<>();

for(String str : set) {
    System.out.println(str);
}
复制代码

迭代 Set 还可以通过 Stream API 实现。用 Set 创建一个 Stream,调用 forEach方法进行元素迭代

Set set = new HashSet<>();

set.add("one");
set.add("two");
set.add("three");

Stream stream = set.stream();

stream.forEach((element) -> { System.out.println(element); });
复制代码

Stream 的具体用法,等到了Stream 章节再详细学习。

有序的 Set

上面操作都是通过 HashSet 给大家演示的,因为都是实现自 Java Collection 的 Set 接口,所以 其他几种 Set -- LinkedSet、TreeSet 也都可以使用与 HashSet 相同的操作方法进行操作。

不过 Set 的几种实现类除了操作的相似性之外,也具有不同的地方,不然 Java 提供这么多实现类也就没啥意义了,在这些不同中最明显能让使用者感觉出来的就是他们的元素排列顺序、或者叫迭代时元素的顺序是不同的。

LinkedHashSet

HashSet 因为底层是由 HashMap 实现的,所以它是个无序的集合,而 LinkedHashSet 是可以保证其元素的排列顺序与元素的插入顺序保持一致的,其实这点跟 List 很像。另外,重新插入一个已经在 LinkedHashSet 中的元素不会改变这个元素已有的顺序。

LinkedHashSet 的元素排列顺序跟插入顺序一致,在需要保证插入顺序又要元素唯一的场景适合使用,下面介绍另外一种有序 Set。

TreeSet

另外一种有序的 Set 类 -- TreeSet 中的元素是按自然排序或者用户指定比较器 (Comparator)排序的 Set,就是说插入元素到 TreeSet 时,是按照排序规则放到某个位置的,这点跟数据库的索引有点像,实际上也确实是这样, TreeSet 依赖的底层数据结构是红黑树,每次往树中插入节点是会排列树的。

比如现在我们有这样一个保存字符串的 TreeSet 实例

import java.util.TreeSet;

public class TreeSetDemoApp {
    public static void main(String[] args) {
        TreeSet ts = new TreeSet();
        ts.add("ccc");
        ts.add("aaa");
        ts.add("ddd");
        ts.add("bbb");

        System.out.println(ts); // [aaa, bbb, ccc, ddd]

    }
}
复制代码

虽然它的元素 ccc 第一个插入,可以在输出元素的时候,确实按照字符串的排序规则进行排列的,把它排在了第三位。

那么怎么给 TreeSet 给元素排序的规则是什么呢?首先,如果元素的类型本身是可比较的类型,就按照类型的比较规则进行排序,什么是可比较类型呢?即实现了 Comparable 接口的类型都是可比较类型,这个接口里只有一个 compareTo 方法。我们用的 Java 自带的内置类都已经实现过这个接口。

假如你把自定义 Class 的实例,放在 TreeSet 中后,只要这个 Class 实现了 Comparable 接口,那么 TreeSet 就会按照实现里的规则对它持有的元素进行排序。

比如下面的 Person 类,就是通过 CompareTo 方法指定了按照对象的 age 属性值进行排序,那么放入到 TreeSet 后,TreeSet 就会按照这个规则对元素进行排序。

class Person implements Comparable {
	private String name;
	private int age;
	private String gender;
 
	public Person() {
 
	}
 
	public Person(String name, int age, String gender) {
 
		this.name = name;
		this.age = age;
		this.gender = gender;
	}
 
    // 省略 Getter 和 Setter 
 
	@Override
	public int compareTo(Object obj) {
		
		Person p = (Person) obj;
		System.out.println(this+" compareTo:"+p);
		if (this.age > p.age) {
			return 1;
		}
		if (this.age < p.age) {
			return -1;
		}
		return this.name.compareTo(p.name);
	}
}
复制代码

除此之外,还有一种方法是给 TreeSet 容器设置指定一个排序器(Comparator),比如上面的例子,不让 Person 实现 Comparable 接口的话也可以通过给 TreeSet 指定这样一个排序起达到同样的效果。


class MyComparator implements Comparator {
 
	public int compare(Person o1, Person o2) {
		Person p = (Person) obj;
		System.out.println(this+" compareTo:"+p);
		if (this.age > p.age) {
			return 1;
		}
		if (this.age < p.age) {
			return -1;
		}
		return this.name.compareTo(p.name);
	}
}

public class TreeSetDemoApp {
    public static void main(String[] args) {
        TreeSet ts2 = new TreeSet<>(new MyComparator());

        ts2.add(new Person("cc", 17, "男"));
        ts2.add(new Person("dd", 17, "女"));
        ts2.add(new Person("aa", 15, "女"));
        ts2.add(new Person("dd", 15, "女"));

        System.out.println(ts2);
    }
}
复制代码

上面示例中的 TreeSet 在排列元素的时候,会先按照元素的年龄大小进行排序,相等了再按照名字进行排序,所以元素的排列的顺序会是这样。

[Person [name=aa, age=15, gender=女], Person [name=dd, age=15, gender=女], Person [name=cc, age=17, gender=男], Person [name=dd, age=17, gender=女]]
复制代码

总结

Set 跟 List 的主要区别是不允许重复对象,这在有些场景会减少复杂度,提高程序的效率。 Set 中的HashSet 是无序集合,不能像 List 一样使元素的排列顺序和插入顺序保存一致,想要使用有序 Set 可以选择 LinkedHashSet 和 TreeSet,TreeSet 更是能让我们指定比较器,让元素在插入时就进行好排序。

不过这里介绍的 Set 容器都不是线程安全的,等讲到 Java 并发时咱们会在介绍 Set 相关的并发容器。


 

 

你可能感兴趣的:(java,jvm,数据结构)