黑马程序员——集合框架3:Set集合

------- android培训java培训、期待与您交流! ----------

1.  Set集合特点

       Set集合的特点在《集合框架2:List集合》中简单介绍过,这里我们再强调一下。Set集合刚好与List集合相反:

(1)    元素是无序排列的;

(2)    元素不可以重复。

也就是说,Set集合存入元素的顺序和取出元素的顺序不一定相同。

2.  Set集合共性方法

       我们首先还是查阅Set接口的API文档。文档第一句话就告诉我们:一个不包含重复元素的 collection。

       接着,我们去查阅Set接口的方法摘要,显然Set接口的方法与Collection接口是完全一致的,并没有像List集合那样定义了丰富的特有方法。因此,这里就不再重复去介绍这些方法了,大家可以去参考《集合框架1:体系概述》。

3.  Set集合子类介绍

       在Set接口API文档开头,所有已知实现类一栏下呈现了很多实现子类,下面我们重点介绍其中两个:HashSet和TreeSet。

 

小知识点1:

       另外,需要提醒大家的是,凡是类名前缀为Abstract的,均是抽象类,这些抽象类在继承父类(或实现接口)的同时,复写了其中一部分方法,又将另一部分方法留给下面的子类去具体实现,因为具体集合类底层的数据结构不同,相应的一些方法的实现原理也不同。

3.1  HashSet

3.1.1  HashSet底层数据结构

       HashSet集合底层使用的是哈希表数据结构(或称为散列表)。我们都知道,Object类的toString方法的打印内容为:

getClass().getName() + '@' +Integer.toHexString(hashCode())

上式中hashCode返回的是该对象的哈希值,API文档中是这么描述的:实际上,由Object类定义的hashCode方法确实会针对不同的对象返回不同的整数(这一般是通过将该对象的内部地址转换成一个整数来实现的,但是Java编程语言不需要这种实现技巧)。因此我们可以将哈希值简单理解为对象在堆内存中的地址值。另外,我们需要提一句的是,正像上文中括号内最后一句所说,hashCode的计算方法并不需要由Java编程语言实现,而是调用由JVM所在系统的本地方法来帮助我们计算的。

       那么某个程序在一次执行过程中创建的对象,在堆内存中的地址值是固定的一个数(但不保证多次运行时这个值还是固定的),而哈希表中存储的就是,集合中元素对象的哈希值(地址值)

       我们以下面的代码为例说明哈希表数据结构的原理。

代码1:

import java.util.*;
 
class Demo{}
 
class HashSetDemo
{
	public static void main(String[] args) 
	{
		Demo d1 = new Demo();
		Demo d2 = new Demo();
		System.out.println(d1);
		System.out.println(d2);
 
		//创建一个HashSet集合
		HashSet hs = new HashSet();
		//仿照Object类的toString方法打印HashSet集合的信息
		System.out.println(hs.getClass().getName()+"@"+hs.hashCode());
		hs.add(d1);
		hs.add(d2);
	}
}

执行结果为:

Demo@1db9742

Demo@106d69c

java.util.HashSet@0

       上述三行运行结果“@”后十六进制表示的数字就是两Demo对象以及HashSet集合对象的哈希值。我们用下图简单的表示哈希表的结构。


图1 哈希表数据结构

       在哈希表中元素的存储顺序是按照哈希值的大小排列的,与存储顺序没有关系。因此虽然首先向HashSet集合中存储了地址为1db9742的Demo对象。但是由于106d69c小于1db9742,因此,在哈希表中的元素顺序首先是106d69c,其次才是1db9742,取出元素也是遵循地址值大小的顺序。到此,我们了解了哈希表的一个简单结构,以及其中存储的内容,下面我们来介绍,哈希表示如何保证存入元素的唯一性。

3.1.2  HastSet集合保证元素的特有方式

       对于存入哈希表的元素,会进行两步判断,以此来决定是否存储该元素,或者说待存储元素是否已存在与集合中。假设现需要将A(哈希值106d69c)对象存入集合中,那么首先,通过hashCode方法计算的A的哈希值,并与集合内所有元素的哈希值进行比较(集合内的哈希值存储于哈希表中),如果均不相同,就直接将该对象存入集合中;假设某个元素B的哈希值与A相同,那么接着就会通过equals方法,判断A与B是否为同一个对象。如果为同一个对象,就不存储A,否则就在B所在地址上顺延一个位置存储A,或者说在同一个哈希值位上存储两个对象,就像下图所示。


图2 哈希表数据结构相同哈希值元素存储示意图

       这里需要说明的是,无论hashCode还是equals方法,都不需要手动调用,在add方法存储某个元素时,底层会自动调用这两个方法。

       那么可能有朋友会问,如果哈希值是由对象的地址值换算而来,那么两个对象的哈希值在什么情况下会相同呢?当人为的去复写某个类的hashCode方法,方法体刻意返回某个整型数,如下代码所示,

代码2:

class Demo
{
	public int hashCode()
	{
		//人为定义返回一个整型数
		return 100;
	}
}
       这样一来,通过new关键字创建的Demo对象的哈希值都为100。那么如果向HashSet集合中存入多个Demo,那么这些对象就都会存储在哈希表值100的位置上。

3.1.3  HashSet集合存储原理

需求1:向HashSet集合中存储字符串对象,然后获取这些元素并打印。

代码3:

import java.util.*;
 
class HashSetDemo2
{
	public static void main(String[] args) 
	{
		HashSet hs = new HashSet();
 
		//故意添加一些相同元素,并打印存储是否成功
		System.out.println(hs.add("String1"));
		System.out.println(hs.add("String1"));
		hs.add("String2");
		hs.add("String3");
		hs.add("String3");
 
		for(Iterator it = hs.iterator(); it.hasNext(); )
		{
			System.out.println(it.next());
		}
	}
}
运行结果为:

true

false

String3

String2

String1

       从该运行结果中我们可以得到三个信息:1. 当存储第二个"String1"时,因存储失败而打印false;2. 存储顺序与去除顺序不同;3. 去除了重复元素。

需求2:HashSet集合中存储自定义对象

第一种方式:

代码4:

import java.util.*;
 
class Person
{
	private String name;
	private int age;
	Person(String name, int age)
	{
		this.name = name;
		this.age = age;
	}
 
	//为方便演示,略去了name和age成员变量对应的set方法
	public String getName()
	{
		return name;
	}
	public int getAge()
	{
		return age;
	}
	public boolean equals(Object obj)
	{
		if(!(objinstanceof Person))
			throw new IllegalArgumentException("所传参数类型不符!");
 
		Person p = (Person)obj;
                               
		//打印调用方法对象姓名和参数对象姓名,
		//以此观察HashSet集合的存储过程
		System.out.println(this.name+"...equals..."+p.getName());
 
		return this.name.equals(p.getName()) &&this.age == p.getAge();
	}
}
class HashSetDemo3
{
	public static void main(String[] args) 
	{
		HashSet hs = new HashSet();
 
		hs.add(new Person("Jack", 31));
		hs.add(new Person("Peter", 23));
		hs.add(new Person("Lucy", 18));
		hs.add(new Person("Jack", 31));
 
		for(Iterator it = hs.iterator(); it.hasNext(); )
		{
			Person p = (Person)it.next();
			System.out.println("name= "+p.getName()+", age= "+p.getAge());
		}
	}
}
运行结果为:
name=Lucy,age=18
name=Jack,age=31
name=Peter,age=23
name=Jack,age=31
       首先,从结果来看,即使Person类复写了equals方法,也未能去除掉集合中的重复元素;另外,并没有调用equals方法。之所以会产生这样的结果是因为,HashSet集合保证元素唯一性是通过两步判断:哈希值和equals方法。由于代码4中向集合中存储的四个Person对象均是通过new关键字创建出来的,因此他们的地址值或者哈希值均不相同,因此不再通过equals方法判断对象是否相同,最终结果就是将四个对象都存入了集合中。

第二种方式:

       那么解决这个问题的方法就是手动复写自定义类的hashCode方法:equals的判断条件是什么,就以该条件来定义计算hashCode方法。

       因此,我们在Person类复写hashCode方法,我们首先使用一种简单的方法,这里我们仅呈现Person类hashCode方法的代码,

代码5:

class Person
{
	//为便于演示,这里只显示hashCode方法的内容
	public int hashCode()
	{
		//为便于观察元素的存储过程,当元素对象调用hashCode方法时
		//向控制台打印调用者的姓名
		System.out.println(this.name+"......hashCode");
		//这里我们返回一个固定的整型数
		return 60;
	}
}
再次运行代码4,结果为:

Jack......hashCode

Peter......hashCode
Peter...equals...Jack
Lucy......hashCode
Lucy...equals...Peter
Lucy...equals...Jack
Jack......hashCode
Jack...equals...Lucy
Jack...equals...Peter
Jack...equals...Jack
name= Lucy, age= 18
name= Peter, age= 23
name= Jack, age= 31
       从结果来看,hashCode和equals方法确实被调用了,这是因为所有Person对象的哈希值均被定义为了60,因此在进行元素唯一性判断时,全部都要通过equals方法判断两对象是否相同。下面就来详细描述一下,上述运行结果对应的元素存储过程。
       第一,开始向HashSet集合中存储第一个Person对象——"Jack"。首先计算"Jack"的哈希值(与此同时打印了"Jack"的姓名)。然后开始遍历集合中的元素,由于此时集合中没有元素,因此比较了哈希值并且没有找到相同哈希值元素后,并没有通过equals判断(所以并没有执行equals方法的输出语句)。所以就将该对象存入集合中,存储位置对应的哈希值为60。
       第二,向集合中存储"Peter",首先计算了"Peter"的哈希值,同时打印了调用hashCode方法的元素姓名——"Peter",这里注意,并没有再次打印"Jack"的姓名,也就是说并没有再次计算"Jack"的哈希值。这是因为集合中已有元素的哈希值保存在了哈希表中,不必重复计算。然后遍历集合中的元素,与集合中唯一元素"Jack"的哈希值进行比较,两哈希值相同,而后通过equals方法进行判断,同时打印了"Peter"(equals调用者)和"Jack"(equals参数)的姓名,而返回值为false,所以将"Peter"存储在"Jack"所在位置上,也就是说"Jack"和"Peter"存储在了同一个哈希表位(60)上。
       第三,向集合中存储"Lucy",同样计算"Lucy"的哈希值,并打印。遍历集合中元素,先后分别与"Peter"和"Jack"进行比较,哈希值均相同,equals方法返回值均为false(同样,在调用equals方法的同时,打印了调用者"Lucy",参数对象"Peter"和"Jack"的姓名),因此又在同一个哈希表位(60)上存储了"Lucy"。
       最后,再次存储"Jack",计算了"Jack"的哈希值。遍历到集合内已有元素"Jack"时,两元素哈希值相同,并且equals方法返回值也为true,因此不再存储"Jack"(遍历过程中三次调用equals方法,分别打印了调用者"Jack"、参数对象"Lucy"、"Peter"和"Jack"的姓名)。
       在上述存储过程中,因为所有对象的哈希值都相同,所以每次都会通过equals方法判断两对象是否相同,这就导致整个过程非常繁琐。解决这一问题的思路就是:按照equals方法的判断条件生成每个对象的哈希值。
第三种方式:
代码6:

class Person
{
	//为便于演示,这里同样只显示hashCode方法的内容
	public int hashCode()
	{
		//为便于观察元素的存储过程,当元素对象调用hashCode方法时
		//向控制台打印调用者的姓名
		System.out.println(this.name+"......hashCode");

		return this.name.hashCode()+age;
	}
}
在代码4中添加代码6以后,再次执行代码,运行结果为:
Jack......hashCode
Peter......hashCode
Lucy......hashCode
Jack......hashCode
Jack...equals...Jack
name= Peter, age= 23
name= Lucy, age= 18
name= Jack, age= 31
       从结果看,存储元素的过程明显简单了许多,只需要计算待存储元素的哈希值,并只调用了一次equals方法进行判断。这是因为,"Jack"、"Peter"和"Lucy"的姓名年龄均不相同,那么通过姓名年龄计算得来的哈希值也必然不相同,因此不必每次都通过equals方法判断。当再一次存储"Jack"的时候不仅哈希值相同,而且equals方法返回也是true,所以存储失败。
       另外,我们可以对上述代码6中的hashCode方法做进一步的优化:

return this.name.hashCode()+age*27;
假设B为待存储元素,而B对象name属性的哈希值为24,其age属性为26,而恰好A元素name属性的哈希值位27,其age属性为23。这样一来,两对象的哈希值恰好相同,这时就需要进一步调用equals方法了。那么为了尽可能避免这种情况的发生,可以在某个属性值上乘以某个数。可能有的朋友想到将两属性值相乘作为哈希值返回,这样做原理上是可以的,只是注意不要超出int类型变量的取值范围。
       那么通过上述几个例子,我们可以做出如下总结:在实际开发过程中,当我们需要自定义类时,尽可能复写hashCode和equals方法,以便于在有需要的时候将对象存储于HashSet集合中。对于equals方法,通过类的成员变量是否相等来判断两对象是否相同;而对于hashCode方法,计算哈希值的方法,可以与equals的判断条件相同,这样可以尽量减少equals方法的调用次数。

3.1.4  HashSet集合contains和remove执行原理

       我们首先来看下面的代码,
代码7:
import java.util.*;

class Person
{
	private String name;
	privateint age;
	Person(String name, int age)
	{
		this.name = name;
		this.age = age;
	}

	public String getName()
	{
		return name;
	}
	public int getAge()
	{
		return age;
	}
	public int hashCode()
	{
		//为便于观察元素的存储过程,当元素对象调用hashCode方法时
		//向控制台打印调用者的姓名
		System.out.println(this.name+"......hashCode");
		return this.name.hashCode()+age;
	}
	public boolean equals(Object obj)
	{
		if(!(objinstanceof Person))
			throw new IllegalArgumentException("所传参数类型不符!");

		Person p = (Person)obj;

		//打印调用方法对象姓名和参数对象姓名,
		//以此观察HashSet集合的存储过程
		System.out.println(this.name+"...equals..."+p.getName());

		return this.name.equals(p.getName()) &&this.age == p.getAge();
	}
}
class HashSetDemo4
{
	public static void main(String[] args) 
	{
		HashSet hs = new HashSet();

		//向集合中存储三个元素
		hs.add(new Person("Jack", 31));
		hs.add(new Person("Peter", 23));
		hs.add(new Person("Lucy", 18));

		//判断"Jack"是否包含在集合中,并打印结果
		System.out.println(hs.contains(new Person("Jack",31)));

		System.out.println("=======================");

		//删除"Peter"元素
		System.out.println(hs.remove(new Person("Peter", 23)));
	}
}
运行结果为:
Jack......hashCode
Peter......hashCode
Lucy......hashCode
Jack......hashCode
Jack...equals...Jack
true
=======================
Peter......hashCode
Peter...equals...Peter
true
       前三行结果表明在向集合中分别存储三个元素时调用了三次hashCode。而后,在通过contains方法判断Jack是否包含于集合中时,首先计算了Jack的哈希值,并找到集合中包含相同哈希值的元素,最后通过equals方法判断两元素是否相同,最终结果为true。同样,调用remove方法时,首先计算了Peter的哈希值,找到相同哈希值的元素以后,再通过equals方法判断两对象相同,并返回true后,删除了Peter。
       通过上述两个方法的执行过程,我们知道,只要涉及到判断某个元素是否存在于HashSet集合中时,都要进行两步判断:一是判断两元素的哈希值;二是通过equals方法判断两对象是否相同。这与之前所说的HashSet集合保证元素唯一性的原理相同,因为若要保证元素的唯一性,就必须判断待添加元素是否存在在集合中。
相对于HashSet判断元素是否包含于集合中依赖于hashCode与equals方法,ArrayList集合只依靠equals方法,而这一区别的产生完全是由于这两种集合底层所使用的数据结构决定的。

3.1.5  哈希算

(1)  哈希算法原理

        根据上述原理,当需要向集合中存储元素,或者在集合中查找指定元素时,都要将被存储对象的哈希值与集合中每个元素的哈希值进行比对。而当集合中元素数量非常长多的时候,比较过程可能非常耗时。

        因此为了提高HashSet存储、查找元素的效率,有人发明了一种哈希算法。这种算法将哈希表分为不同的部分,假如分为5个部分。那么在存储元素时,对被存储元素的哈希值除以5,如果余数为3,则把该对象的哈希值与哈希表第3部分中元素的哈希值逐一进行比较,如果没有发现相同哈希值,就将该元素存储到集合中的第5部分中。因此这一算法就将哈希值的比较过程限制在了一部分元素范围内,而无需与所有元素哈希值进行比较,大大提高了HashSet的存储、查找效率。

        下图简单描述了上述哈希算法的原理,可以帮助大家理解。假设某个对象的哈希值为10f3,表示为十进制为4339。假设哈希表分为5部分,则4339除以5余4,则与第4部分中的元素进行比较。


        那么当使用哈希算法时,如果某个类没有复写hashCode方法,其实例对象的哈希值就默认通过堆内存地址值换算得出。在这种情况下,假设向集合中存储某个对象A,若该对象中的内容(成员变量)与集合中的元素B相同,但由于二者的内存地址不同,因此很可能不会将A对象的哈希值与B元素所在区域中元素哈希值逐一进行比较。换句话说,明明集合中已经存在了与A对象“相同”的对象,也会将A存储进来。即使碰巧二者哈希值除5(假设哈希表分为5部分)的余数相等,但是由于二者哈希值的差异,A对象最终还是存储到了集合中,而且与B分在了同一个区域。

        此外,Object类的hashCode方法的API文档中有这样的描述:如果根据equals(Object)方法,两个对象时相等的,那么对这两个对象中的每个对象调用hashCode方法都必须生成相同的整数结果。由于通常情况下,hashCode方法和equals方法都是按照对象的成员变量值计算的,因此当两个相同对象调用其中任意一个方法时的比较结果都应该是一样的,否则依然可能会出现存储重复元素的情况。
       综上所述,哈希算法的主要作用是提高hashSet的执行效率,而哈希算法能否起到作用,前提就是被存储对象一定要复写hashCode方法,否则,无论是否使用哈希算法,HashSet集合都无法保证元素的唯一性。

(2)  内存溢出

       这里我们需要提醒大家的是:当一个对象被存储进HashSet集合中以后,就尽可能不要再修改对象中的那些参与计算哈希值的字段了。这是因为,对象在存储进HashSet集合中时,其哈希值同时被存储到了哈希表中,而对象虽然可以被修改,但对应的哈希值却没有随之改变。也就是说,对象被修改后的哈希值与最初存储进HashSet集合中的哈希值是不同的。在这种情况下,即使调用contains方法使用该对象的当前引用作为参数去HashSet集合中检索该对象,也将找不到指定对象。这也会导致无法从HashSet集合中单独删除当前对象,那么进而,如果在删除对象时,没有进行删除成功判断,那么那些已经认为被删除的对象依旧留存在了集合中,久而久之,就有可能造成内存泄露。以下代码即演示了这种情况,

代码8:

//需要为Person类定义setAge和setName方法
import java.util.HashSet;
 
public class HashSetDemo5 {
	public static void main(String[] args) {
		Person p = new Person("David",26);
               
		HashSet<Person> hs = new HashSet<Person>();
		hs.add(p);
               
		p.setAge(33);
		System.out.println(hs.remove(p));
	}
}

执行结果为:

false

       结果表明,虽然是同一个对象,但是由于对象内容被改变(准确的说,是参与计算哈希值的成员变量被改变),而导致无法删除指定对象。

       那么有可能有这样的面试题,问:Java中有没有内存泄露?为什么?答案肯定是有。那么我么可以这样回答:所谓内存泄露,就是指不需要的对象大量占用内存空降,并最终超出了虚拟机默认的内存空间范围,而最终导致Java程序无法正常执行的现象。那么正常情况下,Java程序是不会出现内存泄露的,这是因为执行一个Java程序时,Java虚拟机会默认开启一个线程进行垃圾回收,用于处理那些已经不再被任何引用所指向的对象。此时,我们就可以通过上面HashSet的例子来说明,Java可能出现内存泄露的原因。

3.1.6  总结

       最后我们来对HashSet集合进行总结。
       首先,HashSet集合的特点有二:(1) 按照哈希值大小对所存元素进行排序;(2) 保证元素的唯一性。
       第二,HashSet集合底层的数据结构为哈希表数据结构。
       第三,HashSet集合通过hashCode与equals方法,保证元素的唯一性。

3.2  TreeSet

       上面的内容中我们介绍了Set接口的实现类HashSet集合底层的数据接口、保证元素唯一性的原理以及一些方法的简单演示,下面我们继续介绍Set接口的另一个常用实现子类TreeSet集合,同样介绍该集合类的底层数据结构及其保证元素唯一性的特有方式。

3.2.1  TreeSet存储数据的特点

(1)  TreeSet集合存取字符串对象的特点
       我们先通过下面的代码——向TreeSet集合中存储字符串对象——来对TreeSet作一个初步的了解,
代码9:
import java.util.*;

class TreeSetDemo
	{
		public static void main(String[] args) 
		{
			TreeSet ts = new TreeSet();

			//向集合中存储一些字符串对象
			ts.add("Dog");
			ts.add("Cat");
			ts.add("Pig");
			ts.add("Sheep");

			//去除并打印集合中的所有元素
			for(Iterator it = ts.iterator(); it.hasNext(); )
			{
				System.out.println(it.next());
			}
		}
}
运行结果为:
Cat
Dog
Pig
Sheep
       上述代码的运行结果有两个特点:1,元素的取出顺序和存储顺序并不相同,这符合了Set集合的特点;2,如果仔细观察,取出元素的顺序是按照字母顺序排列的。并且,无论如何改变上述四个元素的存储顺序,取出顺序都是固定不变的——也就是说, TreeSet集合会自动按照元素本身的特点,对其进行排序。
(2)  TreeSet集合存取自定义对象的特点
       在代码8的例程中我们演示了TreeSet集合存取字符串对象的特点,接下来我们来演示TreeSet集合存取自定义对象的特点。
需求:向TreeSet集合中存储自定义Person对象,希望集合中元素按照年龄排序。
代码10:
import java.util.*;

class Person
{
	private String name;
	private int age;

	Person(String name, int age)
	{
		this.name = name;
		this.age = age;
	}

	//为演示方便,略去了name和age成员变量对应的set方法
	public String getName()
	{
		return name;
	}
	public int getAge()
	{
		return age;
	}
}

class TreeSetDemo2
{
	public static void main(String[] args) 
	{
		TreeSet ts = new TreeSet();

		//向集合中存储若干自定义Person对象
		ts.add(new Person("Wilson",23));
		ts.add(new Person("Kate",18));
		ts.add(new Person("Tom",33));
		ts.add(new Person("David",28));

		//从集合中去除Person对象,并打印元素的信息
		for(Iterator it = ts.iterator(); it.hasNext(); )
		{
			Person p = (Person)it.next();
			System.out.println(p.getName()+"......"+p.getAge());
		}
	}
}
       执行上述代码就会报出以下错误提示:
Exception in thread "main" java.lang.ClassCastException: Person cannot be cast to java.lang.Comparable
at java.util.TreeMap.compare(TreeMap.java:1290)
at java.util.TreeMap.put(TreeMap.java:538)
at java.util.TreeSet.add(TreeSet.java:255)
at TreeSetDemo2.main(TreeSetDemo2.java:34)
ClassCastException表示类型转换异常,这个异常我们在前面的内容中是提到过的。大家可能会认为是在通过for循环取出元素时,将Object对象向下转型为Person时出现了异常,但是,异常信息告诉我们,是在存储第二个元素的时候出现的问题(at TreeSetDemo2.main(TreeSetDemo2.java:34))。此时,如果我们将存储后三个元素的语句注释掉,就不再抛出任何异常了,大家可以自行尝试。这就说明,并不是在将Object类型转换为Person时出现了问题,我们再去仔细阅读错误提示的第一行:Person cannot be cast to java.lang.Comparable。此时我们去查阅Java标准类库lang包中Comparable的API文档:此接口强行对实现它的每个类的对象进行整体排序。这种排序被称为类的自然排序,类的compareTo方法被称为它的自然比较方法。
       我们结合前述的异常提示和Comparable接口的API文档,来解释出现上述错误的原因:从TreeSet集合存取字符串对象的特点来看,该集合类具备对存储元素进行排序的功能。而如果 要实现对元素的排序,其前提是被存储的元素对象必须具备可比较性,只有具备了某种比较性,才能通过这个比较性来分出前后顺序,比如字符串的字母顺序。那么如何让一个类具备比较性呢? Comparable接口的API文档告诉我们,只要实现该接口,就令实现该接口的类具备比较性,而通过这种方式得到的顺序成为自然顺序,或者默认顺序。那么不难想到,代码8中的字符串自身就具备比较性的原因,其实也是实现了Comparable接口的缘故。
       当然,实现某个接口也就必然要去复写该接口的方法,赋予其具体的实现方式。Comparable接口的方法摘要告诉我们,该接口只有一个抽象方法——compareTo。看到这,有些细心的朋友可能会提出这样的问题:我们虽然是可以实现Comparable接口,但是我们依然没有具体定义上述的比较性,以Person类为例,我们依然没有具体定义按照年龄排序的方法。那么我们自然而然的就想到,应该在compareTo方法内定义比较性。compareTo方法的API文档告诉我们:比较此对象与指定对象的顺序。如果该对象小于、等于或大于指定对象,则分别返回负整数、零或正整数,因此该方法的返回值类型为int。
下面我们通过具体的代码来实现上述内容,
代码11:
import java.util.*;

//令Person类实现Comparable接口
class Person implements Comparable
{
	private String name;
	private int age;

	Person(String name, int age)
	{
		this.name = name;
		this.age = age;
	}

	//为演示方便,略去了name和age成员变量对应的set方法
	public String getName()
	{
		return name;
	}
	public int getAge()
	{
		return age;
	}
	//复写compareTo方法
	public int compareTo(Object obj)
	{
		if(!(objinstanceof Person))
			throw new IllegalArgumentException("传递参数类型不符!");

		Person p = (Person)obj;

		/*
		为了观察元素的存储过程
		在调用compareTo方法时进行输出打印的动作
		同样打印调用该方法的对象姓名,和参数对象姓名
		*/
		System.out.println(this.name+"...compareTo..."+p.getName());

		/*
			下面我们按照compareTo方法API文档的规定,来定义返回值
		*/
		//如果此对象年龄大于参数对象年龄,返回1
		if(this.age>p.getAge())
			return 1;
		//如果此对象年龄等于参数对象年龄,返回0
		if(this.age == p.getAge())
			return 0;
		//如果此对象年龄小于参数对象年龄,返回-1
		return -1;
	}
}

class TreeSetDemo3
{
	public static void main(String[] args) 
	{
		TreeSet ts = new TreeSet();

		//向集合中存储若干自定义Person对象
		ts.add(new Person("Wilson",23));
		ts.add(new Person("Kate",18));
		ts.add(new Person("Tom",33));
		ts.add(new Person("David",28));

		System.out.println("===================================");

		//从集合中取出Person对象,并打印元素的信息
		for(Iterator it = ts.iterator(); it.hasNext(); )
		{
			Person p = (Person)it.next();
			System.out.println(p.getName()+"......"+p.getAge());
		}
	}
}
运行结果为:
Wilson...compareTo...Wilson
Kate...compareTo...Wilson
Tom...compareTo...Wilson
David...compareTo...Wilson
David...compareTo...Tom
===================================
Kate......18
Wilson......23
David......28
Tom......33
       分隔符以上的内容为,向集合存储元素的过程中,调用compareTo方法的情况,而分隔符以下的内容是从集合中取出元素的顺序。从结果来看,集合确实对所存元素进行了排序,并且是按照年龄顺序排序的。
       下面我们就上面代码详细说明,TreeSet集合存储元素的过程。
       首先,向集合中存储了"Wilson",此时集合中并没有任何元素,因此不需要进行比较,也就没有调用compareTo方法。
       接着,向集合中存储"Kate"。此时,集合在调用"Kate"对象的compareTo方法的同时,开始遍历集合中的元素(此时集合中只有"Wilson"一个元素),并将这些元素作为参数传递到compareTo方法中,分别打印了方法调用者姓名和参数对象姓名,接着比较了该方法调用者的年龄与参数对象的年龄。由于"Kate"的年龄小于"Wilson",显然返回值为-1,最后按照这个返回值将"Kate"存储到了集合中,而从最终取出并打印元素的结果来看,"Kate"被排到了"Wilson"的前面,因此我们可以推断,compareTo方法的返回值若为负,则将参数元素排到该元素前面。
       第三,用同样的方式存储"Tom"。调用"Tom"的compareTo方法,遍历集合中的元素,从运行结果来看,遍历又是从"Wilson"开始的。而由于"Tom"的年龄大于"Wilson",显然compareTo方法的返回值为1,而从结果来看确实将"Tom"排到了"Wilson"的后面。另一个需要我们注意的是,虽然对集合中的元素进行了遍历,但实际上,只打印了"Wilson"一个元素的姓名,这是因为确定了要将"Tom"存储到"Wilson"的后面,并且"Wilson"后面并没有任何元素以后,就没有必要再去和其他元素比较了。
最后,存储"David"。依然从"Wilson"开始遍历集合,重复上述过程,不同的是,"David"年龄大于"Wilson",但是此时"Wilson"后面还有一个元素,因此又与"Tom"进行了比较,最终确定了"David"的存储位置为"Wilson"和"Tom"之间。
       对于上述过程的描述,我们需要进行三点补充说明:首先,compareTo方法是通过add方法向集合中存储元素时,在底层自动调用的,就像向HashSet集合中存储元素时,自动调用hashCode和equals方法一样。其次,虽然我们在描述排列元素顺序时,使用了“前面”、“后面”等词语,给人一种TressSet集合底层使用了数组结构的错误印象,但实际上并非如此,关于TreeSet集合的数据结构我们将在下面的内容中详细介绍。第三,如果我们尝试存储两相同年龄的Person对象,那么第二个Person对象将存储失败。 也就是说compareTo方法的返回值为0时,就不再存储参数对象了,这也就是TreeSet集合保证元素唯一性的方法,与HashSet集合有本质区别。
(3)  对compareTo方法的进一步优化
       在上面的内容中,我们基本实现了将Person对象按照姓名排序的需求。下面我们针对上述补充说明的第三点思考这样的问题:在现实生活中,同年龄而不同姓名是普遍情况,我们不能说年龄相同,就将两个人判定为同一个人。换句话说,我们的本意是将年龄作为排序的标准,而现在却成了判断重复元素的唯一标准。因此,在compareTo方法中,我们除了要比较年龄这一主要条件,还要在两年龄相同的情况下比较姓名这一次要条件。我们就按照这一思路来修改,代码10Person类的compareTo方法。
代码12:
import java.util.*;

//令Person类实现Comparable接口
class Person implements Comparable
{
	private String name;
	private int age;

	Person(String name, int age)
	{
		this.name = name;
		this.age = age;
	}

	//为演示方便,略去了name和age成员变量对应的set方法
	public String getName()
	{
		return name;
	}
	public int getAge()
	{
		return age;
	}
	//复写compareTo方法
	public int compareTo(Object obj)
	{
		if(!(objinstanceof Person))
			throw new IllegalArgumentException("传递参数类型不符!");

		Person p = (Person)obj;

		/*
			为了观察元素的存储过程
			在调用compareTo方法时进行输出打印的动作
			同样打印调用该方法的对象姓名,和参数对象姓名
		*/
		System.out.println(this.name+"...compareTo..."+p.getName());

		/*
			下面我们按照compareTo方法API文档的规定,来定义返回值
		*/
		//如果此对象年龄大于参数对象年龄,返回1
		if(this.age>p.getAge())
			return 1;
		/*
			如果此对象年龄等于参数对象年龄
			就返回两对象姓名属性compareTo方法的比较结果
			希望两同年龄对象按照姓名排序
		*/
		if(this.age == p.getAge())
		{
			return this.name.compareTo(p.getName());
		}
		//如果此对象年龄小于参数对象年龄,返回-1
		return -1;
	}
}

class TreeSetDemo4
{
	public static void main(String[] args) 
	{
		TreeSet ts = new TreeSet();

		//向集合中存储若干自定义Person对象
		ts.add(new Person("Wilson",23));
		ts.add(new Person("Kate",18));
		ts.add(new Person("Tom",33));
		ts.add(new Person("David",28));
		//这里我们再添加一个与David同年龄不同姓名的Person对象
		ts.add(new Person("Lucy",28));

		System.out.println("===================================");

		//从集合中取出Person对象,并打印元素的信息
		for(Iterator it = ts.iterator(); it.hasNext(); )
		{
			Person p = (Person)it.next();
			System.out.println(p.getName()+"......"+p.getAge());
		}
	}
}
运行结果为:
Wilson...compareTo...Wilson
Kate...compareTo...Wilson
Tom...compareTo...Wilson
David...compareTo...Wilson
David...compareTo...Tom
Lucy...compareTo...Wilson
Lucy...compareTo...Tom
Lucy...compareTo...David
===================================
Kate......18
Wilson......23
David......28
Lucy......28
Tom......33
       结果显示,Lucy确实得以存储到集合中,并且排列顺序是按照姓名字符串的字母顺序排列的。当然,如果姓名和年龄均相同的情况下,compareTo方法的返回就只能是0,那么重复元素就无法存储了。
       那么针对这一部分的内容,我们做出如下总结:当需要通过TreeSet集合对存储的元素进行排序时,不仅要判断主要条件,还要判断次要条件,以此来实现Set集合保证元素唯一性的特性。

3.2.2  TreeSet集合底层数据结构

       TreeSet集合既然可以对所存元素进行排序,那么就要在元素之间进行比较,如果是数组结构,那么随着存储元素数量的增多,比较次数也会不断增加,为了进一步提高元素的存储效率,TreeSet集合底层使用的另一种数据结构——二叉树,或者称为红黑树,这也是TreeSet集合名称的由来。
(1)  存储元素原理
       我们还是以Person对现象为例进行说明。我们要向一个TreeSet集合中按顺序存储以下六个Person对象:
第一个元素:new Person(“Lucy”,23);
第二个元素:new Person(“David”,32);
第三个元素:new Person(“Wilson”,19);
第四个元素:new Person(“Peter”,45);
第五个元素:new Person(“Cook”,15);
第六个元素:new Person(“Harry”,29);
第七个元素:new Person(“Tom”,29);
第八个元素:new Person(“Lily”,25);
       我们用下图表示二叉树数据结构,元素之间的相对位置和存储顺序,每个椭圆代表一个元素,红色数字代表存储顺序。元素的存储过程与代码10的运行过程类似,这里不再详细描述。

图3 二叉树数据结构元素存储原理示意图
       首先,存储Lucy,并且Lucy将成为该集合的元素起始点,也就是说之后存储的元素将首先与Lucy进行比较。这里我们假设比较条件依然为年龄和姓名。接着存储David,二叉树的特点是,元素之间的相对位置只有左右之分,因此David的年龄比Lucy大,那么其位置就在Lucy右边。Wilson的年龄比Lucy小,因此其在集合中的位置在Lucy左侧。Peter的年龄大于Lucy,同时也大于David,最终置于David右边。Cook的年龄既小于Lucy,又小于Wilson,最终排到了Wilson的左边。Harry的年龄大于Lucy,但小于David,因此置于David的左侧。Tom的年龄大于Lucy,小于David,等于Harry,但是姓名的首字母顺序在Harry之后,因此排在了Harry的右边。最后一个一个元素Lily,她的年龄大于Lucy、小于David、小于Harry,排到了Harry的左边。这就是形成上述二叉树结构过程的简单描述。
       从上述描述过的程来看,由于第一个元素起着一个分水岭的角色,因此待存储元素不必和每个元素都进行比较,如果被安排在了第一个元素的右侧,就不必再和左侧的所有元素进行比较了,而且在之后的比较过程中,总是可以免去与一部分元素比较的必要。这样的结构,可以在保证元素唯一性的前提下,大大提高了元素的存储速度。

小知识点2;
       不过,在某些情况下,如果每个元素都比前一个元素“大”(或者小),并且元素数量非常多,那么由此生成的二叉树结构与数组结构类似,就像下图所示,

图4 类似数组结构的二叉树数据结构
       因此,为了提高TreeSet集合的存储速度,当元素数量达到一定程度后,就会将中间位置的元素重新设置为起始比较元素,那么在此之后待存储元素就将首先和这个新的起始比较元素进行比较。
(2)  获取元素原理
       总的来说,二叉树结构获取元素的原则是:从“小”到“大”地取。这里我们沿用图3的结构来说明二叉树结构是如何获取到集合中的所有元素的。
       首先,左边的元素总是比较“小”的,因此第一个获取的元素是处于左边分支的末尾元素——Cook。Cook周围没有别的元素,那么下一个就是Wilson。以此类推,获取到的第三个元素是Lucy。接下来就要获取Lucy右边分支的元素,同样从小到大取,因此获取的第四个元素是Lily,接着是Harry。Harry右边和上边的元素都比Harry大,但是显然Tom比David小,因此第六个获取到的元素时Tom,然后才是David,最后是Peter。那么这一取出元素的顺序,严格满足了从小到大的原则,体现了TreeSet集合对所存元素的进行排序的特点。

图5 二叉树结构获取元素原理示意图

小知识点3:
       TreeSet集合的默认取出顺序为从小到大,那么如果在某些情况下需要从大到小地取该怎么办呢?目前,最直接的办法就是修改Person类的compareTo方法。当B元素调用compareTo方法与参数元素A进行比较时,若B年龄大于A则返回-1,反之,返回1。当年龄相同时,计算返回值的语句修改为如下代码:
return p.getName.compareTo(this.name);
上述代码起的同样也是取反的作用。大家可以自行思考,在作出如上修改以后,存储元素的具体过程是怎样的。通常在实际开发过程中,如果确实需要逆向获取集合中的元素,一般是通过一个专门用于操作集合的类的静态方法完成。
(3)  将二叉树结构转为数组结构
       所谓将二叉树结构转为数组,实际就是元素的存储顺序与取出顺序相同。实现方法其实很简单,只需将compareTo方法的返回值强制设置为1(或其他正数)即可,而不需要进行其它任何动作。这样一来,无论存储什么元素都将存储到前一个元素的右边,那么取出元素时,按照从小到大的顺序,将从头部元素依次向下取出,形式上与数组结构相同。我们通过下面的代码演示实现方法,
代码13:
import java.util.*;

class Person implements Comparable
{
	private String name;
	private int age;

	Person(String name, int age)
	{
		this.name = name;
		this.age = age;
	}

	//为演示方便,略去了name和age成员变量对应的set方法
	public String getName()
	{
		return name;
	}
	public int getAge()
	{
		return age;
	}
	//复写compareTo方法
	public int compareTo(Object obj)
	{
		if(!(obj instanceof Person))
			throw new IllegalArgumentException("传递参数类型不符!");

		//直接返回一个正数
		return 1;
	}
}
class TreeSetDemo5
{
	public static void main(String[] args) 
	{
		TreeSet ts = new TreeSet();

		//向集合中存储若干自定义Person对象
		ts.add(new Person("Wilson",23));
		ts.add(new Person("Kate",18));
		ts.add(new Person("Tom",33));
		ts.add(new Person("David",28));
		//这里我们再添加一个与David同年龄不同姓名的Person对象
		ts.add(new Person("Lucy",28));

		//从集合中取出Person对象,并打印元素的信息
		for(Iterator it = ts.iterator(); it.hasNext(); )
		{
			Person p = (Person)it.next();
			System.out.println(p.getName()+"......"+p.getAge());
		}
	}
}
运行结果为:
Wilson......23
Kate......18
Tom......33
David......28
Lucy......28
       上述取出顺序与存储顺序是完全相同的。我们简单说明一下上述代码存储元素的过程。首先向集合中存储了Wilson,接着存储Kate,由于compareTo方法返回值为1,Kate被存储到了Wilson的右边,以此类推,后面的所有元素均存储到了前一个元素的右边,最终的结果与图4所示结构相同。而从集合中取出元素时,按照从小到大的原则,首先取出Wilson,然后沿着右边的分支顺序依次向下取出。最终的效果就是存储顺序与取出顺序相同。如果想逆序取出元素,只需将compareTo方法返回值强制设置为一个负数即可,大家可以自行尝试。另外,如果将compareTo方法的返回值设置为0,那么结果就是只能存储一个元素,因为第一个元素以外的元素都将被判定为重复元素。

3.2.3  通过Comparator对TreeSet集合中的元素排序

(1)  Comparator接口的由来和概述
       在定义一个类的时候,通过实现Comparable接口,并复写compareTo的方法,使该类具备比较性,进而可以通过TreeSet集合对该类的对象进行排序。但是在实际开发中,设计之初可能并不需要令某个类具备比较性,或者某个类已经通过Comparable接口具备了比较性,但在后期开发过程中,又需要该类具备其他的比较性。如果在这两种情况下,直接对以前的代码进行修改是非常麻烦的而低效的。
       那么为了解决上述问题,下面我们来介绍Comparator接口。通过Comparator接口对元素进行排序的原理,不是令元素自身具备比较性,而是让集合本身具备对元素进行排序的功能。换句话说,通过Comparable接口对元素排序,集合的作用是指挥元素相互之间进行比较,因为集合总是通过调用元素本身的compareTo方法进行判断;而通过Comparator接口实现排序,是由集合本身来直接决定元素的顺序,这是这两者之间的区别。TreeSet类API文档构造方法摘要中有如下构造方法:
TreeSet(Comparator<? super E> comparator)
API文档对它的解释为:构造一个新的空TreeSet,它根据指定比较其进行排序。Comparator就是这里指的比较器,也就是说,TreeSet对象一初始化,就通过传入的参数——Comparator实现子类对象——具备了对元素进行排序的功能。
       Comparator既然是接口,那么必然需要定义一个类去实现该接口,并复写该接口中的方法,从其API文档方法摘要得知,我们需要复写的方法有两个:compare和equals。compare方法的原理与compareTo方法类似,只是参数列表为两个同类对象,显然就是要对这两个对象进行比较,并返回正数、负数或0,来对元素进行排序。至于equals方法是不需要专门复写的,因为只要定义一个类就默认继承了Object,自然也就继承了equals方法。
(2)  Comparator接口应用演示
       我们通过下面的代码来演示Comparator接口的使用方法,
代码14:
import java.util.*;

//自定义比较器类,实现Comparator接口,令元素按照姓名进行排序
class MyComparator implements Comparator
{
	public int compare(Object o1, Object o2)
	{
		if(!(o1 instanceof Person && o2 instanceof Person))
			throw new IllegalArgumentException("参数类型不符!");

		Person p1 = (Person)o1;
		Person p2 = (Person)o2;

		/*
			字符串类本身具备比较性,直接调用其compareTo方法即可
			当姓名相同时,再去比较次要条件年龄
			因此首先通过一个整型变量记录姓名比较结果
			当该结果为0时,直接返回年龄比较结果即可
		*/
		int value = p1.getName().compareTo(p2.getName());

		/*
			Integer类本身也实现了Comparable接口
			创建两个Integer对象,并将两元素的姓名传入其构造函数中
			然后直接返回两Integer对象compareTo方法的结果即可
		*/
		if(value == 0)
			return new Integer(p1.getAge()).compareTo(new
		Integer(p2.getAge()));

		//如果value不为0,直接返回value即可
		return value;
	}
}
class Person implements Comparable
{
	private String name;
	private int age;

	Person(String name, int age)
	{
		this.name = name;
		this.age = age;
	}

	//为演示方便,略去了name和age成员变量对应的set方法
	public String getName()
	{
		return name;
	}
	public int getAge()
	{
		return age;
	}
	public int compareTo(Object obj)
	{
		if(!(obj instanceof Person))
			throw new IllegalArgumentException("传递参数类型不符!");

		Person p = (Person)obj;

		if(this.age>p.getAge())
			return 1;
		if(this.age == p.getAge())
		{
			return this.name.compareTo(p.getName());
		}
		return -1;
	}
}
class TreeSetDemo6
{
	public static void main(String[] args) 
	{
		//向TreeSet对象初始化一个比较器对象
		TreeSet ts = new TreeSet(new MyComparator());

		ts.add(new Person("Wilson",23));
		ts.add(new Person("Kate",18));
		ts.add(new Person("Tom",33));
		ts.add(new Person("David",28));
		ts.add(new Person("Lucy",28));
		ts.add(new Person("Tom",25));

		for(Iterator it = ts.iterator(); it.hasNext(); )
		{
			Person p = (Person)it.next();
			System.out.println(p.getName()+"......"+p.getAge());
		}
	}
}
运行结果为:
David......28
Kate......18
Lucy......28
Tom......25
Tom......33
Wilson......23
       结果显示,将集合中的元素按照姓名进行了排序,并且即使两对象的姓名相同,也可以通过年龄分出前后顺序。这个例子就再次体现了接口的存在对程序扩展性的提高作用。

3.2.4  TreeSet集合练习

需求:按照字符串长度排序。
思路:定义一个比较器类,复写compare方法。首先通过Integer对象比较两字符串的长度,并使用一个整型变量记录该结果。若该结果为0,则返回字符串本身compareTo方法的结果(也即主要条件为字符串长度,次要条件为字符串字母顺序);若不为0,则直接放回Integer对象的比较结果。
代码:
代码15:

import java.util.*;

class StrLenComp implements Comparator
{
	public int compare(Object o1, Object o2)
	{
		if(!(o1 instanceof String && o2 instanceof String))
			throw new IllegalArgumentException("参数类型不符!");

		String str1 = (String)o1;
		String str2 = (String)o2;

		//先通过Integer比较两字符串长度
		int value = new Integer(str1.length()).compareTo(new
		Integer(str2.length()));

		//若长度相同,则比较两字符串字母顺序
		if(value == 0)
			return str1.compareTo(str2);

		//若长度不同,则返回长度比较结果
		return value;
	}
}
class TreeSetTest
{
	public static void main(String[] args) 
	{
		TreeSet ts = new TreeSet(new StrLenComp());

		ts.add("Today");
		ts.add("Cat");
		ts.add("Table");
		ts.add("Refrigerator");
		ts.add("Alligator");
		ts.add("Generate");

		for(Iterator it = ts.iterator(); it.hasNext(); )
		{
			System.out.println(it.next());
		}
	}
}
运行结果为:
Cat
Table
Today
Generate
Alligator
Refrigerator
结果表明,将存储的字符串对象按照长度,从小到大进行了排序,并且如果长度相同,就按照字符串的字母顺序进行了排序。在上例的需求也可以通过匿名内部类的方式实现,代码如下,
代码16:
class TreeSetTest2
{
	public static void main(String[] args) 
	{
		//这里使用匿名内部类的方法
		TreeSet ts = new TreeSet(new Comparator()
		{
			public int compare(Object o1, Object o2)
			{
				if(!(o1 instanceof String && o2 instanceof String))
					throw new IllegalArgumentException("参数类型不符!");

				String str1 = (String)o1;
				String str2 = (String)o2;

				//先通过Integer比较两字符串长度
				int value = new Integer(str1.length()).compareTo(new Integer(str2.length()));

				//若长度相同,则比较两字符串字母顺序
				if(value == 0)
					return str1.compareTo(str2);

				//若长度不同,则返回长度比较结果
				return value;
			}
		});

		ts.add("Today");
		ts.add("Cat");
		ts.add("Table");
		ts.add("Refrigerator");
		ts.add("Alligator");
		ts.add("Generate");

		for(Iterator it = ts.iterator(); it.hasNext(); )
		{
			System.out.println(it.next());
		}
	}
}
运行结果是一样,这里不再演示。

3.2.5  总结

       最后我们来对TreeSet集合进行一个总结。
       首先,TreeSet集合的特点有二:(1) 保证元素的唯一性;(2) 对所存的元素,按照指定规则排序。
       第二,TreeSet集合底层数据结构为二叉树数据结构。
       第三,TreeSet集合前述两个特点有两种实现方式:(1) 令元素类实现Comparable接口,复写compareTo方法,简单说就是令元素本身具备比较性;(2) 为TreeSet集合对象初始化一个Comparator对象,令集合本身具备为元素排序的功能。而当两种方式都存在时,以比较器为主,也就是说,无论元素是否具备比较性,只要在创建TreeSet对象时为其初始化了比较器对象,那么排序时就按比较器的排序方法排序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值