Java开发笔记(六十五)集合:HashSet和TreeSet

对于相同类型的一组数据,虽然Java已经提供了数组加以表达,但是数组的结构实在太简单了,第一它无法直接添加新元素,第二它只能按照线性排列,故而数组用于基本的操作倒还凑合,若要用于复杂的处理就无法胜任了。为此Java设计了一大类的数据类型名叫容器,它们仿佛容纳物品的器皿一般,可大可小,既能随时往里塞入新物件,又能随时从中取出某物件。当然,依据不同的用途,容器也分为好几类,包括集合Set、映射Map、清单List等等,本文先从最基础的集合开始介绍。
所谓集合,指的是一群同类聚集在一起,集合的最大特点就是里面的每个事物都是唯一的,即使重复加入也只算同一个元素。Java给集合分配的类型名称叫做“Set”,在使用之时还得在Set后面补充一对尖括号,里面填集合内部元素的数据类型。比如一个字符串集合,它的完整类型写法为“Set<String>”,下面便是声明字符串集合变量的代码例子:

		Set<String> set;

 

可是由于Set实际上属于接口,因此不能直接用来创建集合实例,在编程开发中,往往使用Set的两个实现类HashSet和TreeSet。
HashSet的大名叫哈希集合,其内部采取哈希表来存储数据;而TreeSet的大名叫做二叉集合,其内部采取二叉树来存储数据。尽管HashSet与TreeSet二者的存储结构不同,但它们在编码调用时大体类似,所以接下来就以HashSet为例,概要描述集合的基本用法。
一开始使用集合,当然也要先创建该集合的实例。创建集合实例的方式跟创建一个类的实例相同,都得调用它们的构造方法,集合实例的具体创建代码如下所示:

		HashSet<String> set = new HashSet<String>();

 

有了集合实例,再通过实例去调用具体的集合方法,以下是常用的集合方法说明:
add:把指定元素添加到集合。
remove:从集合中删除指定元素。
contains:判断集合是否包含指定元素。
clear:清空集合。
isEmpty:判断集合是否为空。
size:获取集合的大小(即所包含元素的个数)。
从以上说明可见,这些集合方法还是蛮基础的,不但基础而且通用,不光是集合会用到这些方法,连映射和清单也要用到它们,因而后面在介绍映射和清单之时,就不再重复说明上述的基本方法了。
接着来个集合的初步运用,功能很简单,仅仅往字符串集合添加五个字符串,然后获取并打印该集合的大小,示例代码如下:

		HashSet<String> set = new HashSet<String>();
		set.add("hello");
		set.add("world");
		set.add("how");
		set.add("are");
		set.add("you");
		System.out.println("set.size()=" + set.size());

 

运行上面的测试代码,可得日志结果为“set.size()=5”。不过这里只获得集合大小,若想知晓集合内部到底有哪些字符串,还得依次遍历该集合的所有元素才行。集合元素的遍历方式主要有三种:for循环遍历、迭代器遍历、forEach遍历,接下来分别进行介绍。

1、for循环遍历
这个for循环属于简化的for循环,早在遍历数组元素的时候,大家已经见识过了。废话不必多说,直接看下列代码好了:

		// 第一种遍历方式:简化的for循环同样适用于数组和容器
		for (String hash_item : set) {
			System.out.println("hash_item=" + hash_item);
		}

 

运行以上的循环代码,输出了如下的日志信息,可见该集合的五个元素全都找到了:

hash_item=how
hash_item=world
hash_item=are
hash_item=hello
hash_item=you

  

2、迭代器遍历
迭代器又称指示器,其作用类似于数据库的游标,以及C语言的指针。调用集合实例的iterator方法即可获得该集合的迭代器,初始的迭代器指向集合的存储地址;它的hasNext方法用来判断后方是否存在集合元素,倘若不存在则表示到末尾了;迭代器另有next方法用于获取下一个元素,同时迭代器移动到下一个地址。于是多次调用集合实例的next方法,即可逐次取出该集合的每个元素。下面是利用迭代器遍历集合的代码例子:

		// 第二种遍历方式:利用迭代器循环遍历集合。
		Iterator<String> iterator = set.iterator();
		while (iterator.hasNext()) { // 迭代器后方是否存在元素
			// 获取迭代器后方的元素
			String hash_iterator = (String) iterator.next();
			System.out.println("hash_iterator=" + hash_iterator);
		}

  

3、forEach遍历
forEach是Java8新增的容器遍历方法,同样适用于映射和清单。它借助Lambda表达式能够完成最简化的遍历操作,仅仅一行代码就搞定了集合元素的循环输出功能,具体实现代码如下所示:

		// 第三种遍历方式:使用forEach方法夹带Lambda表达式进行遍历
		set.forEach(hash_each -> System.out.println("hash_each=" + hash_each));

 

讲完了集合的三种遍历方式,按说集合的常见用法均涉及到了,那么为啥集合还要分成哈希集合与二叉集合两类呢?这缘于集合的基本特性规定了集合里的每个元素是唯一的,但并未规定集合里的元素需要按照顺序排列。从前面哈希集合的遍历结果可知,哈希集合里面保存的各元素是无序的,因为一个数据的哈希结果是散列值,天南地北到处跑,自然无法按照元素值进行排序。二叉集合的设计正是要解决这个顺序问题,由于二叉集合内部采取二叉树存储数据,每个新加入的元素都要与原住民们比较一番,好决定这个新元素是放在某个原住民的左节点还是右节点;因此,倘若把一组字符串先后加入二叉集合,那么每次新增元素的操作都会进行大小比较,最终得到的二叉集合必定是有序的。

为了验证二叉集合的添加操作是否符合设计原理,接下来不妨创建一个二叉集合的实例,再往其中添加多个字符串,然后遍历打印该字符串集合的所有元素。据此重新编写后的二叉集合演示代码如下所示:

		TreeSet<String> set = new TreeSet<String>();
		set.add("hello");
		set.add("world");
		set.add("how");
		set.add("are");
		set.add("you");
		// 第一种遍历方式:简化的for循环同样适用于数组和容器
		for (String tree_item : set) {
			System.out.println("tree_item=" + tree_item);
		}

 

运行上述的演示代码,观察以下的日志信息可知,这个二叉集合的遍历结果为按照字符串首字母升序排列:

tree_item=are
tree_item=hello
tree_item=how
tree_item=world
tree_item=you

  

需要注意的是,不管是哈希值计算,还是二叉节点比较,都需要元素归属的数据类型提供计算方法或者比较方法。对于包装类型、字符串等系统自带的数据类型来说,Java已经在它们的源码中实现了相关方法,所以这些数据类型允许程序员在集合中直接使用。然而如果是开发者自己定义的数据类型(新的类),就要求开发者自己来实现计算方法和比较方法了。
譬如有个自定义的手机类MobilePhone,该类的定义代码见下:

//定义一个手机类
public class MobilePhone {
	private String brand; // 手机品牌
	private Integer price; // 手机价格

	public MobilePhone(String brand, int price) {
		this.brand = brand;
		this.price = price;
	}

	// 获取手机品牌
	public String getBrand() {
		return this.brand;
	}

	// 获取手机价格
	public int getPrice() {
		return this.price;
	}
}

现在给手机类分别创建对应的哈希集合与二叉集合,并对两种集合分别添加若干手机实例,结果会发现,手机的哈希集合居然会插入品牌与价格重复的元素!同时手机的二叉集合也变成乱序的了,因为编译器不晓得究竟要按照品牌排序还是按照价格排序。既然编译器无从判断待添加的元素是否重复,也无法判断新添加的元素根据哪个字段排序,程序员就得在手机类的定义代码中指定相关的判断规则了。

就哈希集合的哈希值计算而言,自定义的手机类需要重写hashCode和equals方法,其中hashCode方法计算得到的哈希值对应于该对象的保存位置,而equals方法用来判断该位置上的几个元素是否完全相等。一方面,我们要保证品牌与价格都相同的两个元素,它们的哈希值必须也相等;另一方面,即使两个元素的品牌和价格不一致,它们的哈希值也可能恰巧相等,于是还需要equals方法进一步校验是否存在重复。按照上述要求,重写后的hashCode和equals方法代码示例如下:

	// hashCode方法计算出来的哈希值对应于该对象的保存位置
	@Override
	public int hashCode() {
		return brand.hashCode() + price.hashCode();
	}

	// 同一个存储位置上可能有多个对象(哈希值恰好相等),
	// 此时系统自动调用equals方法判断是否存在相同的对象。
	@Override
	public boolean equals(Object obj) {
		if (!(obj instanceof MobilePhoneHash)) {
			return false;
		}
		MobilePhoneHash other = (MobilePhoneHash) obj;
		// 手机品牌和手机价格都相等,才算是这两个手机相等
		boolean equals = this.brand.equals(other.brand) && this.price.equals(other.price);
		return equals;
	}

至于二叉集合的节点大小比较,则需手机类实现接口Comparable,并具体定义该接口声明的compareTo方法(该方法用来比较两个元素的大小关系)。其实这里的Comparable接口与数组排序用到的Comparator接口作用类似,都是判断两个对象谁大谁小。如果要求二叉集合里面的手机元素按照价格排序,则compareTo方法主要校验当前手机的价格与其它手机的价格。详细的接口实现代码如下所示:

public class MobilePhoneTree implements Comparable<MobilePhoneTree> {
	// 此处省略手机类的构造方法、成员属性与成员方法定义

	// 二叉树除了检查是否相等,还要判断先后顺序。
	// 相等和先后顺序的校验结果从compareTo方法获得。
	@Override
	public int compareTo(MobilePhoneTree other) {
		if (this.price.compareTo(other.price) > 0) { // 当前价格较高
			return 1;
		} else if (this.price.compareTo(other.price) < 0) { // 当前价格较低
			return -1;
		} else {
			return this.brand.compareTo(other.brand);
		}
	}
}

经过一番折腾之后,再对新定义的两个手机类分别对哈希集合与二叉集合开展验证,结果应当为:哈希集合不会插入重复的手机对象,并且二叉集合里的各手机元素按照价格升序排列。



更多Java技术文章参见《Java开发笔记(序)章节目录

posted @ 2019-02-27 14:38  pinlantu  阅读(515)  评论(0编辑  收藏  举报