数组与其它容器的区别体现在三个方面,效率,类型识别和可以持有primitives,数组是java提供的,能随机存储和访问reference序列的诸多方法中,最有效率的一种。数组的缺点就是:当你创建一个数组的时候,它的容量就确定了,而且在其生命周期不会改变。数组的类型识别,当数组在创建的时候,你就已经知道了(定义了)它所持有的对象是什么类型的,也就是说它会在编译的时候做类型检查,从而防止你插入错误类型的对象或者在提取对象的时候把类型搞错了,这也引出了数组的第三个特点,它可以持有primitives变量,而容器类却不可以。
数组是一流的对象,不管你用的是那一种类型的数组,数组的标识符实际上都是一个“创建在堆中(heap)的实实在在对象”的reference。
注:对象数组持有的是reference,而primitive类型数组则直接持有值。
Java.util这个包中的Arrays类,它包括了一组可用于数组的static方法,其中有四个基本方法:用来比较两个数组是否相等的equals(),用于填充数组的fill(),用于对于数组排序sort(),以及用于在一个已排序的数组查找元素的binarySearch(),所有这些方法都对primitives和Object进行了重载。此外还有一个asList()方法,它接受一个数组,返回的是一个List容器。当你要复制整个数组的时候,java的类库里还提供了一个System.arraycopy()的static方法,相比for循环,这个速度比较快,传给arraycopy()的参数有:源数组,标识从源数组那个位置开始拷贝的偏移量,目标数组,标识从目标数组那个位置开始拷贝的偏移量,以及要拷贝的元素的数量。
注:对象数组和primitive数组都能拷贝,如果你拷贝的是对象数组,那么你只拷贝了他们的reference,对象本身不会被拷贝,这被称为"浅拷贝".
为了比较两个数组是否相等,Arrays还提供了经过重载的equals()方法,来成对各种primitive和Object做相等的判断。两个数组要想完全相等,它们必须要相等的元素数量,并且每个位置上的元素必须和另一个数组相同位置上的元素相等。元素的相等性会用equals()判断。
数组的排序,对于对象数组的排序,Java里有两种能让你实现比较功能的方法,一种是实现java.lang.Comparable接口,并以此实现类“自有的”比较方法。这是一个很简单的接口,它只有一个compareTo()方法,这个方法能接受另一个对象作为参数,如果现有对象比参数小,会返回负数,相等返回零,大则返回正数。另一种通过现实Comparator接口,这个接口有两种方法,compare()和equals()方法,其中compare()这个方法时必须要求overload的。总之不论是primitive还是对象数组,只要它实现了comparable接口或是有一个与之相关Comparator对象(这个对象现实了Comparator的接口),我们就可以对数组排序了。
查询一个有序数组可以使用Arrays.binarySearch()进行快速查询,如果Arrays.binarySearch()找到了,它会返回一个大于或等于零的值,否则会返回一个 -(插入点)-1,这个插入点就是在所有比要找的那个值更大的值中的最小值的下标.如果所有的值都比要找的值小,那么插入点的值就是数组的大小。
注:如果在排序的时候使用了Comparator,在进行Arrays.binarySearch()也需要将这个Comparator作为参数传过去。
总而言之,你要持有一组对象,首先得应该就是数组,接下来我们要讲更为一般的情况,也就是写程序的时候不知道到底要用多少对象,或者要用更为复杂的方式来存储对象。
Java还提供了“容器类”,其基本的类型有List,Set和Map。Java要解决“怎样持有对象“,它把这个问题分成两类:
1、Collection:通常是一组有一定规律的独立的元素。List必须按照特定的顺序持有元素,Set则不能保存重复的元素。Collection只允许每个位置存放一个对象。
2、Map:一组以“键-值”(key-value)形式出现的pair,Map可以返回键(key)的Set,值的Collection或者pair的Set,Map不接受重复的pair,至于是不是重复,要看key的值是不是相同。
容器类的缺点就是你在向容器里放入对象的时候,会把对象类型的信息给弄丢了,这是因为开发容器类的程序员不知道你要用它来存放什么类型的对象,而让容器类仅保存特定类型的对象又会影响其通用性。所以容器被做成了持有Object对象类型,也就是所有对象根类的reference,这样它就可以持有任意类型的对象了。
由于你已经知道容器持有那个对象(Object)的reference,当你要使用容器里的对象的reference的时候,就需要进行类型转换,否则你就只能使用Object根类的方法,而这似乎没有什么意义。值得庆幸的是,Java不会让你误用容器里的对象的。假设你往容器里放了一个A和一个B,当你将B的reference拿出来当A来使用的时候,就会引发一个RuntimeException。
二、迭代器
无论哪种容器,都得有方法将对象放进去,也能将对象取出来。容器的任务就是存放对象。“迭代器”是一种对象,它的任务就是,能在让“程序员不知道或者不关心他所处理的是什么样的底层序列结构”的情况下,就能在序列中前后移动,并选取其中的对象。Java的迭代器是受限制的,它所能做的事情包括:
1.用Iterator()方法让容器传给你一个Iterator对象。
2.用next()方法获取序列中下一个对象。注:第一次调用Iterator的next()方法时返回的是序列中第一个对象 。
3.用hasNext()方法查询序列中时候是否还有其他对象。
4.用remove()方法删除迭代器返回的最后一个元素。
就这些了,需要注意的是对于List,还有一个更为精巧的Listiterator(),Iterator()采用的“全部拿走容器里的所有对象,然后在一个一个的处理”的思路,这使得Iterator()的作用非常强大。例如下面这个通用的打印程序。
Import java.util.*;
public class Printer{
static void printAll(Iterator e){
while(e.hasnext()){
//分别调用容器队象的toString()方法。
System.out.println((String)e.next());
}
}
}
三、容器分类学
容器内类系之间的关系可以通过简化的类系图表示:
对于Collection,Map,List,Set的功能说明,就在这里进行说明,更为具体可以参看JDK的api文档 。我只在这里列出一些经典的用法。
List的功能,通常我们都用List的add()方法加入队象,用get()取对象和用iterator()方法获取这个序列的Iterator对象。有两种List:擅长对元素随机访问的、较常用的ArrayList,以及一个功能更为强大的LinkedList,由于它不是为随机访问设计的,所以用LinkedList做随机访问速度比较慢,但是它却有一些通用的方法。下面就是用LinkedList做了一个栈,“栈”也被称为“后进先出”(LIFO)容器,最后一个压入栈的东西,会第一个弹出来。用LinkedList可以直接实现栈的功能。下面这个类就通过使用LinkedList实现了栈的功能。
package test;
import java.util.*;
public class StackL {
private LinkedList ll = new LinkedList();
//将对象压入栈
public void push(Object ob){
ll.addFirst(ob);
}
//栈顶元素
public Object top(){
return ll.getFirst();
}
//栈顶元素弹出栈
public Object pop(){
return ll.removeFirst();
}
public static void main(String[] args){
StackL sl = new StackL();
//将integer对象压入栈中
for(int i=0;i<10;i++)
sl.push(new Integer(i));
//列出栈顶对象
System.out.println((Integer)sl.top());
//将栈顶对象弹出
System.out.println((Integer)sl.pop());
//再次列出栈顶的对象
System.out.println((Integer)sl.top());
}
}
用LinkedList还可以做一个队列(queue),队列是一个先进先出(FIFO)的容器。也就是说你从一端将东西放进去,从另一端将东西取出来。LinkedList有支持对列的功能方法,所以可以当作对列来用。
package test;
import java.util.*;
public class Queue {
private LinkedList ll = new LinkedList();
//在队列当中放入对象
public void put(Object ob){
ll.addFirst(ob);
}
//先被放入队列的对象被取出来
public Object get(){
return ll.removeLast();
}
//判断对列是否为空
public boolean isEmpty(){
return ll.isEmpty();
}
public static void main(String[] args) {
Queue qe = new Queue();
for(int i=0;i<10;i++)
//在队列中放入对象
qe.put(new Integer(i));
//通过判断队列是否为空,输出对列中的对象
while(!qe.isEmpty())
System.out.println((Integer)qe.get());
}
}
Set的功能,Set的接口和Collection是一样的,实际上Set就是一个Collection,只不过行为方式不一样。
对于Set来说有一点需要特别注意:Set拒绝持有多个具有相同值的对象的实例。(对象的值由什么来决定的呢?)让我们带着这个问题继续下去。写自己定义的类的时候一定要注意,Set要有一个标准来判断以什么样的顺序将对象放到Set中区,也就是说你必须实现Comparable接口,并且定义了CompareTo()方法。当你把对象放入Set(无论是那种Set)的时候,都需要定义equals()方法,只有将对象放入HashSet的时候,才需要定义hashCode(),HashSet用了“专为快速查找设计的散列函数”,但是作为一种编程的风格,最好将equals()和hashCode()都覆写了。这里还有提到Set的另一个接口SortedSet,它下面只有一个实现TreeSet,使用 SortedSet shs = new TreeSet();SortedSet里面的元素是有序的,这使得SortedSet接口多了些有用的方法,例如:SortedSort.headSet(toElement),返回Set子集,其中的元素小于toElement。
SortedSort.tailSet(fromElement) 返回Set子集,其中的元素大于或等于fromElement。
Map的功能,ArrayList能让你将数字和对象关联起来,通过数字你可以在一个序列里进行选择。但是如果我们想根据其他条件在序列里选择呢?比如说我要根据一个对象来选取另一个对象,不用急,java里提供了专门的容器Map来支持。Java的标准类库里有好几Map:HashMap,TreeMap,LinkedHashMap等,他们都实现了Map的基本接口,但是在行为方式方面存在明显的差异,这些差异表现在效率,持有对象和表示对象pair顺序以及如何决定键的相等性方面。比如LinkedHashMap,它很像HashMap,但是在使用Iterator遍历的时候,它会按照插入的顺序或最近最少使用(least-recently-used order)的顺序输出对象, 这样还没有被访问过的对象就会排在最前边,利用这个特性写一个定时清理的程序会很简单。而查找对象则是HashMap的强项,因为它采用了一种被称为hash code的特殊值来查找, HashMap就是通过hash code进行查找,这样性能就大幅的提高了。
那么散列算法和产生的Hash数究竟是什么样的呢?它们之间有存在什么关系呢?下面为您揭开Hash数的神秘面纱。
散列(hash)是一种算法,它会从目标对象中提取一些信息,然后生成一个表示这个对象的相对独立的int值,hashCode()是Object根类的方法,因此所有的java对象都能生成hash code。一般情况下,我们在使用自己写的类作Map的键时,如果我们没有覆写Object根类的hashCode()方法,那么它就会使用Object根类的hashCode()方法,而产生的Hash数就是对象的内存地址,在这样的情况下,我们自己写的Person类。
public class Person {
protected String idCard;
public Person(String sidCard){
idCard = sidCard;
}
public String toString(){
return "Persion's idcard is "+ idCard;
}
}
注意:Person(“139898988967664788”)和另一个实例Person(“139898988967664788”)却是不相等的。
这样我们使用同一个类的不同实例来查找,也不会找到Map的键值的。因为我们没有覆写hashCode()方法,注意:在覆写键类的hashCode()方法的同时,也应该将equlas()覆写了,HashMap要用equals()来判断查询的键是不是与表里的其他值相等。一个合适的equals()必须做到以下几点:
1.自反性:对任何x,x.equals(x)返回的值一定为true;
2.对称性:对任何x、y,当且仅当y.equals(x)为true时,x.equals(y)也一定为true;
3.传递性:对任何x、y、z,当x.equals(y)为true,且y.equals(z)为true时,x.equals(z)也为true;
4.一致性:对任何x、y,如果用于equals()方法比较的对象的信息没有被修改,那么调用多少次x.equals(y)都会一致返回true或false;
5.非空性:对于任何非空的x,那么x.equals(null)一定是false;
默认的Object.equals()只是简单比较两个对象的地址,所以在使用自己写的类作HashMap的键的话,你就必须把hashCode()和equals()都给覆写了。讲到这里我们还不知道散列数据结构是怎么运行的?散列可以帮你把键存到某个你能很快找到的地方。正如前面所说的数组是最快的数据结构,所以散列也用数组表示键的信息(注:是键的信息,而不是键本身)。
此外,数组一经分配就不能调整大小了,而Map要能存储任意数量的Pair,键的数量不是受到数组大小的限制了吗?答案就是,不用数组来存储键本身,键对象会生成一个数字,我们要用这个数字(这就是所谓得hash数,它是由对象的hashCode()散列函数生成的。)作为下标来访问数组,而解决定长数组的问题就得允许许多键生成同一个hash数,也就是说会有不同的键对象产生冲突。这样数组的大小就无关紧要了,每个键对象都会落到数组的某个位置上。而散列解决冲突的办法是通过“外部链”,数组并不是直接指向对象,而是指向一个对象的列表,然后再用equals方法在这个对象列表中一个一个的找。这一步是比较慢的,但是如果你得散列函数定义的好,一个以这个hash数作为下标的数组成员对应的对象列表,只包含几个对象。这也是散列数据结构为什么这么快的原因。
上面我们说了散列数据结构的原理,不用我再说什么大家都会影响HashMap散列性能的因素,不过这里介绍几个术语:
Capacity:hash表里bucket的数量。
Initial Capacity:创建hash表时,bucket的数量。HashMap和HashSet都要让你指定Initial Capacity的构造函数。
Size:当前hash表记录的数量。
Load factor:当Load factor达到某个设定的阙值后,容器会自动将Capacity扩大大约一倍,然后将现有对象的分配到新的bucket中去。HashMap和HashSet都要让你指定Load factor的构造函数。默认的情况下HashMap的Load factor为0.75。
因此,我们在自己写的类中覆写hashCode()方法时候,就应该注意了,首先,你控制的不是在bucket数组里进行检索的值,这个值是随散列数据结构(如:HashMap)的Capacity,Size,Load factor变化而变化的。hashCode()返回的值还要做进一步处理才能得到bucket数组的下标。要想让hashCode()充分发挥作用,它必须既快还有意义,也就是说必须根据其内容生成值,由于hashCode()返回的值还要做进一步处理,所以它的取值范围并不重要;只要是int就可以了。java的牛人Joshua Block给出了hash数一个好的算式:
hash值= 37*result+c;
result是一个非零的常量,如17。对于每个重要的数据成员f(equals()要用得所有数据成员),分别计算其int型的hash值C。
四、选择实现
现在你应该知道了,实际上只有三种容器组件:Map,List和Set,但是每个组件又有多种实现,那我们用到这些组件时,应该选择哪种实现呢?
其实容器与容器之间的差别,真正的原因在于其实现,说明白点就是实现Interface的数据结构是什么。像类系图所示的ArrayList和LinkedList都是List的实现,所以无论那个基本功能都是一样的,但是ArrayList背后是数组,而LinkedList是所谓的双向链表,每个对象除了保存数据之外,还要保存它前面和后面对象的reference,所以你要在List中间做很多插入和删除操作的话,LinkedList就比较适合。
Set也有HashSet,TreeSet和LinkedHashSet三个实现,HashSet是我们通常所用的set, LinkedHashSet会按照插入的顺序保存对象,而TreeSet背后是TreeMap,因此能够提供恒定有序的Set,大多数情况下HashSet就够用了。
Map同样也有HashMap,TreeMap,LinkedHashMap等多个实现,如果我们自己写一个测试这几个实现的性能的类的话,你就会发现HashMap比TreeMap,LinkedHashMap都要快,但是TreeMap还有一个特殊的用处,保存一个有序的列表,因为树总是有序的,所以用不着为它作排序,往TreeMap里填满pair后,你就能用KeySet()获取这个Map的键的Set了,接下来用toArray()把这Set转化为数组,然后用Array的static方法binarySearch()在这个有序的数组里进行快速查找对象了。总之,要根据不同的情况,使用相应的容器。
Java的容器虽说有点复杂,但是还只值得大家去好好的研究下。熟练的使用容器组建,能使我们得编程更具有灵活性了。