Java Review (二十六、集合----- Set 集合)

2020-07-16 13:13:54 浏览数 (1)

Set 集合,它类似于一个罐子 , 程序可以依次把多个对象"丢进" Set 集合,而 Set集合通常不能记住元素的添加顺序 。 Set 集合与 Collection 基本相同,没有提供任何额外的方法。实际上 Set 就是 Collection , 只是行为略有不同( Set不允许包含重复元素) 。

HashSet 类

散列表(hashtable )是一种可以快速地査找所需要的对象的数据结构, 散列表为每个对象计算一个整数, 称为散列码(hashcode)。 散列码是由对象的实例域产生的一个整数。更准确地说, 具有不同数据域的对象将产生不同的散列码。

HashSet是 Set 接口的典型实现 ,大多数时候使用 Set 集合时就是使用这个实现类。 HashSet 按 Hash算法来存储集合中 的元素,因此具有很好的存取和查找性能。

 HashSet 具有以下特点 :

  • 不能保证元素的排列顺序,顺序可能与添加顺序不同,顺序也有可能发生变化 。
  • HashSet 不是同步的,如果多个线程同时访问 一个 HashSet,假设有两个或者两个以上线程同时修改了 HashSet 集合时,则必须通过代码来保证其同步。
  • 集合元素值可以是 null 。

元素比较

当向 HashSet 集合中存入一个元素时, HashSet 会调用该对象的 hashCode()方法来得到该对象的hashCode 值,然后根据该 hashCode 值决定该对象在 HashSet 中的存储位置。如果有两个元素通过 equals()方法比较返回 true,但它们的 hashCode()方法返回值不相等, HashSet 将会把它们存储在不同的位置, 依然可以添加成功。

HashSet 集合判断两个元素相等的标准是两个对象通过 equals()方法比较相等,并且两个对象的 hashCode()方法返回值也相等。

下面程序分别提供了 三个类 A 、 B 和 C ,它们分别重写了 equals()、 hashCode()两个方法的一个或全部,通过此程序可以了解HashSet 判断集合元素相同的标准 :

HashSetTest.java

代码语言:javascript复制
// 类A的equals方法总是返回true,但没有重写其hashCode()方法
class A
{
	public boolean equals(Object obj)
	{
		return true;
	}
}
// 类B的hashCode()方法总是返回1,但没有重写其equals()方法
class B
{
	public int hashCode()
	{
		return 1;
	}
}
// 类C的hashCode()方法总是返回2,且重写其equals()方法总是返回true
class C
{
	public int hashCode()
	{
		return 2;
	}
	public boolean equals(Object obj)
	{
		return true;
	}
}
public class HashSetTest
{
	public static void main(String[] args)
	{
		HashSet books = new HashSet();
		// 分别向books集合中添加两个A对象,两个B对象,两个C对象
		books.add(new A());
		books.add(new A());
		books.add(new B());
		books.add(new B());
		books.add(new C());
		books.add(new C());
		System.out.println(books);
	}
}

运行结果:

即使两个 A 对象通过 equals()方法 比较返回 true ,但 HashSet 依然把它们当成两个对象:即使两个 B 对象的 hashCode()返回相 同值〈都是1), 但 HashSet 依然把它们 当成两个对象 。

当把一个对象放入 HashSet 中时 ,如果需要重写该对象对应类的 equalsO方法 ,则也应该重写其 hashCode()方法 。 规则是 :如果两个对象通过 equals()方法比较返回 true , 这两个对象的 hashCode 值也应该相同 。

散列表用链表数组实现。每个列表被称为桶( bucket) (参看图二) 要想査找表中对象的位置, 就要先计算它的散列码, 然后与桶的总数取余, 所得到的结果就是保存这个元素的桶的索引。

例如, 如果某个对象的散列码为 76268, 并且有 128 个桶, 对象应该保存在第 108 号桶中(76268除以 128余 108 )。或许会很幸运, 在这个桶中没有 其他元素,此时将元素直接插人到桶中就可以了。

图二、散列表

有时候会遇到桶被占满的情况, 这也是不可避免的。这种现象被称为散列冲突( hash collision) 。 这时, 需要用新对象与桶中的所有对象进行比较,査看这个对象是否已经存在。如果散列码是合理且随机分布的, 桶的数目也足够大, 需要比较的次数就会很少。

当向 HashSet 中添加可变对象时,必须十分小心 。 如果修改 HashSet 集合中 的对象,有可能导致该对象与 集合中的其他对象相等,从而导致 HashSet 无法准确访问该对象 。

API:java.util.HashSet

LinkedHashSet 类

HashSet还有一个子类 LinkedHashSet , LinkedHashSet 集合也是根据元素的 hashCode 值来决定元素的存储位置 , 但它同时使用链表维护元素的次序 ,这样使得元素看起来是以插入的顺序保存的 。 也就是说 , 当遍历 LinkedHashSet 集合里的元素时, LinkedHashSet 将会按元素的添加顺序来访问集合里的元素。

LinkedHashSet 需要维护元素 的插入顺序,因此性能略低于 HashSet 的性能,但在迭代访问 Set 里的全部元素时将有很好的性能,因为它以链表来维护内部顺序 。

LinkedHashSetTest.java

代码语言:javascript复制
public class LinkedHashSetTest
{
	public static void main(String[] args)
	{
		LinkedHashSet books = new LinkedHashSet();
		books.add("疯狂Java讲义");
		books.add("轻量级Java EE企业应用实战");
		System.out.println(books);
		// 删除 疯狂Java讲义
		books.remove("疯狂Java讲义");
		// 重新添加 疯狂Java讲义
		books.add("疯狂Java讲义");
		System.out.println(books);
	}
}

虽然 LinkedHashSet 使用了链表记录集合元素的添加顺序,但 LinkedHashSet 依然是HashSet ,因此它依然不九许集合元素重复 。

API:java.util.LinkedHashSet

TreeSet 类

TreeSet 是 SortedSet 接口的实现类,正如 SortedSet 名字所暗示的, TreeSet 可以确保集合元素处于排序状态。与 HashSet 集合相比, TreeSet 还提供了如下几个额外的方法 :

  • Comparator comparator(): 如 果 TreeSet 采用了定制排序,则该方法返回定制排序所使用的Comparator; 如 果 TreeSet 采用了自然排序,则返回 null 。
  • Object ftrst(): 返回集合中的第一个元素。
  • 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 仕omElement): 返回此Set 的子集 , 由大于或等于企omElement 的元素组成 。

下面程序测试了 TreeSet 的通用用法:

TreeSetTest.java

代码语言:javascript复制
public class TreeSetTest
{
	public static void main(String[] args)
	{
		TreeSet nums = new TreeSet();
		// 向TreeSet中添加四个Integer对象
		nums.add(5);
		nums.add(2);
		nums.add(10);
		nums.add(-9);
		// 输出集合元素,看到集合元素已经处于排序状态
		System.out.println(nums);
		// 输出集合里的第一个元素
		System.out.println(nums.first()); // 输出-9
		// 输出集合里的最后一个元素
		System.out.println(nums.last());  // 输出10
		// 返回小于4的子集,不包含4
		System.out.println(nums.headSet(4)); // 输出[-9, 2]
		// 返回大于5的子集,如果Set中包含5,子集中还包含5
		System.out.println(nums.tailSet(5)); // 输出 [5, 10]
		// 返回大于等于-3,小于4的子集。
		System.out.println(nums.subSet(-3 , 4)); // 输出[2]
	}
}

运行结果:

与 HashSet 集合采用 hash 算法来决定元素 的存储位置不同, TreeSet 采用红黑树的数据结构来存储集合元素。TreeSet 支持两种排序方法 : 自然排序和定制排序。在默认情况下, TreeSet 采用自然排序。

要使用树集, 必须能够比较元素。这些元素必须实现 Comparable 接口, 或者构造集时必须提供一个 Comparator 。

自然排序

TreeSet 会调用集合元素的 compareTo(Object obj)方法来比较元素之间的大小关系,然后将集合元素按升序排列,这种方式就是自然排序 。

Java 提供了 一个 Comparable 接口,该接口里定义了一个 compareTo(Object obj )方法,该方法返回一个整数值,实现该接口的类必须实现该方法 , 实现了 该接口的类的对象就可以比较大小。当一个对象调用该方法与另一个对象进行 比较时,例如 obj 1.compareTo(obj2) ,如果该方法返回 0 ,则表明这两个对象 相等 :如果该方法返回一个正整数, 则表明 objl 大于 obj2; 如果该方法返回一个负整数, 则表明 objl小于 obj2 。

Java 的 一些常用类已经实现了 Comparable 接口,并提供了比较大小的标准。

 下面是实现了Comparable 接口的常用类:

  • BigDecimal 、 BigInteger 以及所有的数值型对应的包装类 : 按它们对应的数值大小进行比较。
  • Character: 按字符的 UNICODE 值进行比较。
  • Boolean: true 对应的包装类实例大于 false 对应的包装类实例。
  • String: 按字符串中字符的UNICODE 值进行 比较。
  • Date 、 Time: 后面的时间、日期比前面的时间、日期大。

如果试图把一个对象添加到 TreeSet 时,则该对象的类必须实现 Comparable 接口,否则程序将会抛出异常。如下程序示范了这个错误:

TreeSetTestError.java

代码语言:javascript复制
class Err{}
public class TreeSetErrorTest
{
	public static void main(String[] args)
	{
		TreeSet ts = new TreeSet();
		// 向TreeSet集合中添加Err对象
		// 自然排序时,Err没实现Comparable接口将会引发错误
		ts.add(new Err());
	}
}

向 TreeSet 中添加的应该是同一个类的对象,否则也会引发ClassCastException 异常,因为大部分类在实现 compareTo(Object obj)方法时,都需要将被比较对象 obj 强制类型转换成相同类型。

如下程序示范了这个错误 :

TreeSetErrorTest2.java

代码语言:javascript复制
public class TreeSetErrorTest2
{
	public static void main(String[] args)
	{
		TreeSet ts = new TreeSet();
		// 向TreeSet集合中添加两个对象
		ts.add(new String("疯狂Java讲义"));
		ts.add(new Date());   // ①
	}
}

当把一个对象加入 TreeSet 集合中时, TreeSet 调用该对象的compareTo(Object obj)方法与容器中的其他对象比较大小,然后根据红黑树结构找到它的存储位置 。 如果两个对象通过 compareTo(Object obj)方法比较相等,新对象将无法添加到 TreeSet 集合中 。

TreeSetTest2.java

代码语言:javascript复制
class Z implements Comparable
{
	int age;
	public Z(int age)
	{
		this.age = age;
	}
	// 重写equals()方法,总是返回true
	public boolean equals(Object obj)
	{
		return true;
	}
	// 重写了compareTo(Object obj)方法,总是返回1
	public int compareTo(Object obj)
	{
		return 1;
	}
}
public class TreeSetTest2
{
	public static void main(String[] args)
	{
		TreeSet set = new TreeSet();
		Z z1 = new Z(6);
		set.add(z1);
		// 第二次添加同一个对象,输出true,表明添加成功
		System.out.println(set.add(z1));    //①
		// 下面输出set集合,将看到有两个元素
		System.out.println(set);
		// 修改set集合的第一个元素的age变量
		 ((Z)(set.first())).age = 9;
		// 输出set集合的最后一个元素的age变量,将看到也变成了9
		System.out.println(((Z)(set.last())).age);
	}
}

运行结果:

程序中①代码行把同 一个对象再次添加到 TreeSet 集合中,因为 zl 对象的 ompareTo(Object obj)方法总是返回 1, 虽然它的 equalsO方法总是返回 true ,但 TreeSet会认为 z1对 象和它自己也不相等,因此TreeSet 可以添加两个 z1 对 象。

TreeSet 及 Z 对象在内存中的存储示意图

由此应该注意一个问题 : 当需要把一个对象放入 TreeSet中, 重写该对象对应类的 equals()方法时,应保证该方法与 compareTo(Object obj)方法有一致的结果,其规则 是 : 如果两个对象通过 equals()方法比较返回 true 时,这两个对象通过 compareTo(Object obj)方法比较应返回 0 。

定制排序

TreeSet 的自 然排序是根据集合元素 的大小, TreeSet 将它们以升序排列 。 如果需要实现定制排序,例如以降序排列, 则可以通过 Comparator 接口 的帮助 。该接口里包含一个 int compare(T 01 , T 02)方法,该方法用于比较 01 和 02 的大小:如果该方法返 回正整数 ,则表明 01 大于 02; 如果该方法返回 0 ,则表明 01 等于 02; 如果该方法返回负整数, 则表 明 01 小于 02 。

如果需要实现定制排序,则需要在创建 TreeSet 集合对象时,提供一个Comparator 对象与该TreeSet集合关联 , 由该 Comparator 对象负责集合元素 的排序逻辑。由于 Comparator 是一个函数式接口,因此可使用 Lambda 表达式来代替 Comparator 对象 。

TreeSetTest4.java

代码语言:javascript复制
class M
{
	int age;
	public M(int age)
	{
		this.age = age;
	}
	public String toString()
	{
		return "M [age:"   age   "]";
	}
}
public class TreeSetTest4
{
	public static void main(String[] args)
	{
		// 此处Lambda表达式的目标类型是Comparator
		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);
	}
}

运行结果:

API:java.util.TreeSet

EnumSet 类

EnumSet 是一个专为枚举类设计的集合类, EnumSet 中的所有元素都必须是指定枚举类型的枚举值,该枚举类型在创建 EnumSet 时显式或隐式地指定。

EnumSet 的集合元素也是有序的, EnumSet 以枚举值在 Enum 类内的定义顺序来决定集合元素的顺序。

EnumSet 在内部以位向 量 的形式存储,这种存储形式非常紧凑 、 高效 ,因此 EnumSet 对象占用内存很小,而且运行效率很好。尤其是进行批量操作(如调用 containsAll() 和 retainAll()方法〉时,如果其参数也是 EnumSet 集合,则该批量操作的执行速度也非常快。

EnumSet 集合不允许加入 null 元素,如果试图插入 null 元素, EnumSet 将抛出 NullPointerException异常。如果只是想判断 EnumSet 是否包含 null 元素或试图删除 null 元素都不会抛出异常,只是删除操作将返回 false,因为没有任何 null 元素被删除。 EnumSet 类没有暴露任何构造器来创建该类的实例,程序应该通过它提供的类方法来创建 EnumSet对象 。 EnumSet 类它提供了如下常用的类方法来创建 EnumSet 对象 :

  • EnumSet allOf(Class elementType): 创建一个包含指定枚举类里所有枚举值的 EnumSet 集合 。
  • EnumSet complementOf(EnumSet s): 创建一个其元素类型与指定 EnumSet 里元素类型相同的 *EnumSet 集合,新 EnumSet 集合包含原 EnumSet 集合所不包含的 、此枚举类剩下的枚举值(即新EnumSet 集合和原 EnumSet 集合的集合元素加起来就是该枚举类的所有枚举值)。
  • EnumSet copyOf(Collection c): 使用 一个普通集合来创建 EnumSet 集合 。
  • EnumSet copyOf(EnumSet s): 创建一个与指定 EnumSet 具有相同元素类型、相同集合元素的EnumSet 集合 。
  • EnumSet noneOf(Class elementType): 创建一个元素类型为指定枚举类型的空 EnumSet 。
  • EnumSet of(E first, E... rest): 创建一个包含一个或多个枚举值 的 EnumSet 集合,传入的多个枚举值必须属于同一个枚举类。
  • EnumSet range(E from, E to): 创建一个包含从 from 枚举值到 to 枚举值范围内所有枚举值的EnumSet集合。

下面程序示范了 如何使用 EnumSet来保存枚举类的多个枚举值 :

EnumSetTest.java

代码语言:javascript复制
enum Season
{
	SPRING,SUMMER,FALL,WINTER
}
public class EnumSetTest
{
	public static void main(String[] args)
	{
		// 创建一个EnumSet集合,集合元素就是Season枚举类的全部枚举值
		EnumSet es1 = EnumSet.allOf(Season.class);
		System.out.println(es1); // 输出[SPRING,SUMMER,FALL,WINTER]
		// 创建一个EnumSet空集合,指定其集合元素是Season类的枚举值。
		EnumSet es2 = EnumSet.noneOf(Season.class);
		System.out.println(es2); // 输出[]
		// 手动添加两个元素
		es2.add(Season.WINTER);
		es2.add(Season.SPRING);
		System.out.println(es2); // 输出[SPRING,WINTER]
		// 以指定枚举值创建EnumSet集合
		EnumSet es3 = EnumSet.of(Season.SUMMER , Season.WINTER);
		System.out.println(es3); // 输出[SUMMER,WINTER]
		EnumSet es4 = EnumSet.range(Season.SUMMER , Season.WINTER);
		System.out.println(es4); // 输出[SUMMER,FALL,WINTER]
		// 新创建的EnumSet集合的元素和es4集合的元素有相同类型,
		// es5的集合元素   es4集合元素 = Season枚举类的全部枚举值
		EnumSet es5 = EnumSet.complementOf(es4);
		System.out.println(es5); // 输出[SPRING]
	}
}

API:java.util.EnumSet

各 Set 实现类的性能分析

HashSet 和 TreeSet 是 Set 的两个典型实现 , 到底如何选择 HashSet 和 TreeSet 呢?

HashSet 的性能总是 比 TreeSet 好(特别是最常用的添加 、 查询元素等操作) , 因为 TreeSet 需要额外的红黑树算法来维护集合元素的次序 。 只有当需要一个保持排序 的 Set 时,才应该使用 TreeSet,否则都应该使用 HashSet 。

HashSet 还有一个子类 : LinkedHashSet , 对于普通的插入、删除操作 , LinkedHashSet 比 HashSet要略微慢一点 , 这是由维护链表所带来的额外开销造成的 , 但由于有了链表,遍历 LinkedHashSet 会更快 。

EnumSet 是所有 Set 实现类中性能最好的,但它只能保存同一个枚举类的枚举值作为集合元素。

必须指出的是, Set 的三个实现类 HashSet 、 TreeSet 和 EnumSet 都是线程不安全的 。如果有多个线程同时访问 一个 Set 集合,并且有超过一个线程修改了该 Set 集合 ,则必须手动保证该Set 集合的同步性。通常可以通过 Collections 工具类的 syncbronizedSortedSet 方法来 "包装"该 Set 集合。 此操作最 好在创建时进行 , 以防止对 Set 集合的意外非同步访问 。

代码语言:javascript复制
SortedSet s = Collections.synchronizedSortedSet(new TreeSet(.. .));

参考: 【1】:《疯狂Java讲义》 【2】:《Java核心技术 卷一》 【3】:廖雪峰的官方网站:使用Set

0 人点赞