Java从零开始 第12讲 Java中的数据结构:类集
类集
在之前我们都用数组在存储多个数据,但是数组存在许多问题
让我们一一阐述数组的缺点:在第10.5讲中的习题中我们实现过能扩容的MyList类,但其实这并不是真正的动态扩容,只是创建了一个新的数组,而且要把原先数组的所有内容一个一个的复制进去,如果数组内容较多,效率将会十分低
数组只能存储同一个类型的数据,并不方便
当我们删除或插入一个元素时,要将后面的元素前移或后移一格,同样在数组内容较多时,效率十分低
数组需要大块连续的内存块,从内存角度不是最优选
为了更好的存储,读取,删改数据,我们理所应当的需要更好的数据结构,在之前都由程序员自行实现各类数据结构,但是Java在JDK2中首次提出了类集的概念,类集就是Java对数据结构的实现。
所有类集操作的类/接口都在java.util包中
类集操作中主要有三个常用的重点类/接口:Collection,Map,和Iterator
Collection被称为单值存储的集合,Map则是双值存储的集合
链表
对于数组想要进行动态扩容,需要通过链表来实现,让我们详细解释一下链表
(注意这里的学习只是为了了解,其中代码都不是真正的实现,因为Java已经帮忙实现了各类数据结构,理解这种结构能够帮助我们更好的使用)
这时我们就可以用链表(Linked List)来解决这些问题,链表是由一组不必相连(即可以连续也可以不连续)的节点(内存结构),按特定的顺序链接在一起的抽象数据类型
Java中可以用类来实现链表,比如我们定义一个Link类:(这种方法只用于理解这个概念)
class Link{
String str;
int num1;
Link link1;
}
注意在最后一行我们又创建了一个Link类的link1对象,就像是相连的链子一样,一环扣一环,所以删除或插入一个元素时,只需要更改链表的前后链,而扩容则只需要在最后新加一个节点
当然链表也有一些缺点,比如链表的存取速度很慢,对于查找或更改数据,数组只需要知道下标就能直接访问,而链表需要一个个顺着链条查找下去
为了提升遍历的速度,在之后又提出了单链表,双向链表,循环链表等
二叉树
二叉树也是一种解决数组的问题的数据结构,二叉树相比于链表更提升了查找数据的速度
二叉树如果用类表示则是:
class Node{
String str;
int num1;
Node right;
Node left;
}
二叉树相比于链表,就是每一个节点指向另外两个子节点,查找元素时可以通过条件判断该去左子节点还是右子节点
Collection接口
Collection 接口是在整个 Java 类集中保存单值的最大操作父接口,每次操作的时候都只能保存一个对象的数据(即只能一个一个改变其中数据)
Collection接口中有很多方法:
重点关注标红的方法,其他的不需要掌握,了解即可
一般情况下不会直接实现Collection接口,而是通过List和Set子接口实现(主要区别为List中元素不能重复,Set可以)
List接口
List接口是Collection接口的子接口
其中对于Collection接口补充了10个方法
而为了实现List接口,通常我们通过继承ArrayList类,Vector类,和LinkedList类的方式
ArrayList类
ArrayList是三个类使用占比最高的类
ArrayList不仅实现了List接口,还实现了RandomAccess接口,Cloneable接口,和Serializable接口,而且继承了AbstractList类
通常我们通过如下方式定义一个List:
List<需要的数据类型> list1 = new ArrayList<同前>();
让我们详细展示一下此类的用法
List<String> all = new ArrayList<String>(); // 实例化List对象,并指定泛型类型
all.add("hello"); // 增加内容
all.add(0, "Start ");// 指定下标增加内容,其后元素后移
all.add("world"); // 增加内容
all.remove(1); // 根据下标删除内容
all.remove("world");// 删除指定的对象
System.out.print("集合中的内容是:");
for (int x = 0; x < all.size(); x++) { // size()方法从Collection接口继承而来
System.out.print(all.get(x) + "、"); // 此方法是List接口单独定义的
}
Vector类
Vector类和ArrayList类继承实现关系完全一样,操作也完全相同,但是他们主要有三个区别
- 时间方面:ArrayList类在JDK2之后推出,Vector类从JDK1开始存在
- 性能方面:ArrayList类采用异步(asynchronous)处理,性能较高;Vector类采用同步(synchronous)处理,性能较低
- 输出方面:ArrayList类仅支持Iterator和List Iterator输出,Vector还额外支持Enumeration输出
同步操作虽然性能较低,但是能避免一些关键的错误(即保证线程安全),这正是Vector(以及之后的Hashtable)仍然在被使用的主要原因,此部分我们在后续多线程部分详细介绍
LinkedList类
LinkedList采用的是双链表的结构,增删快,查找数据比较慢,正好和前面的ArrayList和Vector互补,但实际上LinkedList的使用并不普遍,比前两类都低很多
LinkedList也实现了多个接口,分别为List接口,Deque接口,Cloneable接口,和Serializable接口,继承的则是AbstractSequentialList类
LinkedList中存在模拟堆,栈,和双端队列的模拟:
LinkedList< String> ll1 = new LinkedList< String>();
ll1.push(“qwer”);// 压栈,往栈内存数据
String a = ll1.pop();// 弹栈,得到并删除栈顶元素
String b = ll1.peek();// 得到但不删除
ll1.addFirst(“asdf”);
ll1.addLast(“zxcv”);
ll1.removeFirst();
ll1.removeLast();// 堆,队列,双端队列实现操作
Set接口
Set接口也是Collection的子接口,与List接口最大的不同就在于Set中不允许存在重复内容(因为Set中会根据数据来决定存储的位置,因而最好不要存储可变数据在Set中)
Set接口一般通过它的两个子类实现:HashSet类和TreeSet类
(特别注意:Set中没有定义get方法,无法直接通过下标获取特定数据,必须通过toArray将其转化为数组再查找,或者通过迭代器进行迭代(Iterator,后面讲))
HashSet类
HashSet类采用的是哈希表/散列表存储数据(暂时先不用了解哈希表,知道这是跟堆栈链表一样的数据结构即可)
让我们看看HashSet的存储操作:
Set< String> all = new HashSet< String>();// 创建
all.add("qwer");
all.add("asdf");
all.add("zxcv");// 存入数据,
all.add("zxcv");// 存入重复数据无效
System.out.println(all);// 直接打印
String[] str = all.toArray(new String[] {});// 变为数组
for (int x = 0; x < str.length; x++) {
System.out.print(str[x] + "、");
}
在输出时,你会发现输出的结果顺序并不是你存入的顺序,这正是因为HashSet存储数据是按哈希表的方式存储数据,并不因为传入顺序而改变
TreeSet类
TreeSet类与HashSet不同的点就在于,TreeSet实现的是二叉树结构,而HashSet则是实现哈希表结构。
在这里我们回想一下,之前存储的都是各类数据,但是其实可以通过以上的任何结构存储类的对象,在List的子类中可以直接存储,但是Set的子类中则需要额外实现Comparable接口(因为需要决定存储位置和避免重复数据),重写其中的compareTo方法,HashSet还额外需要重写Object中的equals和hashCode方法
compareTo方法需要你自己重写,并自行制定排序的规则,它的返回值规则是(如果调用语句为c1.compareTo(c2);),如果c1小于c2返回-1,相等返回0,c1大于c2返回1,这时通过比较就能顺着二叉树将数据存于指定位置
compareTo方法例子:
@Override
public int compareTo(Person per) {
if (this.age > per.age) {
return 1;
} else if (this.age < per.age) {
return -1;
} else {
return 0;
}
}
这里我们仅根据年龄的大小排序,如果年龄相等则判断是同一个人
Iterator接口
为了输出集合,可以使用迭代器Iterator进行输出,Iterator接口可以直接使用Collection接口中的iterator()方法进行实例化并使用,其主要有三个方法:
boolean hasNext();// 检查是否有下一个元素
E next();// 取出内容
void remove();// 删除当前内容
让我们来看一个Iterator使用的例子
Collection<String> all = new ArrayList<String>();
all.add("A");
all.add("B");
all.add("C");
all.add("D");
all.add("E");
Iterator<String> iter = all.iterator(); // 此时指针为空,必须next之后才能进行其他操作
while (iter.hasNext()) {// 判断是否有下一个元素
String str = iter.next(); // 取出当前元素,并将指针后移
System.out.print(str + ", ");
}
注意在用Iterator输出时,如果要删除元素,必须使用接口附带的方法iter.remove();而不是集合的方法all.remove();否则将会出现错误
使用Iterator可以完成基础的,从前往后的输出,但是如果需要双向输出,则需要使用其子接口ListIterator
ListIterator
ListIterator帮助解决了双向输出的问题,但是它有一点缺陷就是你必须手动将指针一个一个的挪到最后才能从后向前输出,所以此方法其实并不常用,ListIterator中的方法和Iterator区别不大,这里我们就不详细解释了
ListIterator< String> iter = all.listIterator();// 调用语句差别
注意其中的增加和修改元素操作都是在当前指针位置下添加
Enumeration接口和foreach
Enumeration接口在之前的Vector类中提到过,Enumeration仅支持Vector类的输出,因为Vector类本身就过时了,这个对应的接口也过时了
Enumeration主要有两个常用方法:
boolean hasMoreElements();// 判断是否有下一个元素
E nextElement();// 取出当前元素并下移指针
可以发现其中方法功能和Iterator完全一致,只是更改名称罢了
而foreach则是一种新的支持,是对于for循环的增强,不仅可以用于输出集合,还可以用于输出数组
它通过如下方式使用:
for (String str : all) { System.out.println(str) ; }
其实这个方法一直在被java推广,在我们之前使用的Iterator方法中的while语句中,可以发现while被标了黄框,鼠标移上去发现
如果我们点击Replace with enhanced ‘for’,就会变成
这其实就是foreach的使用,在输出数组时也可以使用
Map接口
Collection接口中的数据操作都是单值操作,而Map则是通过一对值来存储的,通常这一对值被成为键(key)和值(value),就像是一张地图通过经纬度定位一个点一样。当然也不完全一样,比如你存在Map中的数据能过通过一对值中的一个值找到另一个值,并且找
到数据(注意Map其实并不是地图,而是Mapping,映射的缩写)
Map中主要有以下方法,重要的方法同样标红:
int size();// 返回size
注意其中的put和remove方法是有返回值的,put是因为如果检测到key已经存在,则会更改为传入的值并返回之前的旧值,如果是一个新key,则正常存储,返回null;而remove则是返回删除的值,如果找不到传入的key对应的值,则返回null
Map接口主要有三个实现类,分别为HashMap,Hashtable,和TreeMap
之前我们讲过的HashSet和TreeSet类其实就是利用了其中的HashMao和TreeMap类,它们将一对值中的一个值用于存储数据,而用另一个值当作键,用于避免数据的重复
HashMap类
HashMap类是基于哈希表的Map接口的实现,为了能够更好理解HashMap,先让我们介绍以下哈希表
所谓哈希表,就是对每个值都有一个不同的键,如果想要得到对应的值,只需要找到对应的键即可,而哈希表又被成为散列表,就是因为哈希表中的数据存放是分散的,并不像数列需要一整块大的内存。
哈希表的键值一般int类型的非负数,是根据该键存放的值来决定的,我们会通常通过计算该数据的哈希值再求余来决定该键值
存放单个哈希表键的所有值的地方被成为哈希桶,哈希桶的大小即是哈希表一个键存放值的数量,哈希桶的数量即是哈希表不同键的数量
散列因子/负载因子是一个(0, 1)的小数,如果散列因子小,则该哈希表查找效率会很高,但是使用空间会很大;如果散列因子大,则该哈希表空间使用量很小,但是查找效率会很低
而在哈希表的基础上,也可以使用红黑树或链表等方式与之结合(桶大小>8则转为红黑树存放,桶大小<6则转为链表),使查找效率更高,这里我们不再详细解释了
Java中的哈希表并不需要你自己实现,如果想要了解更多可以看HashMap的源码,下面让我们看一个简单的使用例子:
Map<Integer, String> map = new HashMap<Integer, String>();
map.put(1, "张三A");
map.put(1, "张三B"); // 新的内容替换掉旧的内容
map.put(2, "李四");
map.put(3, "王五");
String val = map.get(6);// 查找不到key为6的值,
System.out.println(val);// 所以此处是打印null
Set<Integer> set = map.keySet(); // 得到全部的key
Collection<String> value = map.values(); // 得到全部的value
Iterator<Integer> iter1 = set.iterator();
Iterator<String> iter2 = value.iterator();
System.out.print("全部的key:");
while (iter1.hasNext()) {
System.out.print(iter1.next() + "、");
}
System.out.print("\n全部的value:");
while (iter2.hasNext()) {
System.out.print(iter2.next() + "、");
}
当然也可以只获取全部的key,然后用map.get(key);得到value
之前我们曾提过用类当作Set中存储的数据,但是注意,如果用类当作哈希表的键值,千万不要修改类中的信息,否则相关的哈希表键值将被改变且难以重新找到
Hashtable类
Hashtable之于HashMap,和Vector之于ArrayList完全一样,区别也基本一致
- 时间方面:HashMap类在JDK2之后推出,Hashtable类从JDK1开始存在
- 性能方面:HashMap类采用异步(asynchronous)处理,性能较高;Hashtable类采用同步(synchronous)处理,性能较低
- 存储方面:HashMap支持设置值为null,Hashtable不支持
还有一个ConcurrentHashMap类,采用了分段锁的机制,可以在保证线程安全的同时,维持一个比较高的效率
TreeMap类
TreeMap的区别在于,其可以对key进行排序,而且key的值可以为任意对象,但是对象必须实现Comparable接口,但是该对象正如我们之前所说,也不能随意修改。
十分尴尬的一点是,key的排序在哈希表中并不重要,所以这个类也并不常用
在这里我们简单的贴出代码,如果需要排序也可以使用
// 仅做例子,其中的key和value类可以任意更改
Map<String,Integer> m = new TreeMap<>();
// 如下两行就可以简单的实现升序排列了
List<Map.Entry<String,Integer>> list = new ArrayList<>(m.entrySet());
list.sort(Map.Entry.comparingByValue());
//通过以下方式调用
Map.Entry<String,Integer> ms = list.get(2);
ms.getKey();
ms.getValue();
写在最后
如果你是第一次接触数据结构的概念,即使Java简化了数据结构的内容,本讲中的内容可能仍然对你来说十分晦涩,但是数据结构的选择给大型项目带来的影响是巨大的,在算法已经被不断优化的今天,选择合适的数据结构能够简单的大幅提升整个项目运行效率。