程序 = 算法 + 数据结构,我们在之前的文章中已经和大家分享了Java面向对象编程的基本方法,相信大家都已经可以用Java实现一些简单的算法啦。那么数据结构是什么呢?学过计算机基础课程的同学可能知道,数据结构就是计算器存储、组织数据的方式。很多的理工科学生在本科阶段也会学习这门数据结构课程。

事实上所有的数据结构完全可以通过Java来实现的。但是我们在工程中,不能每次需要使用一种数据结构,都要亲自把它实现一遍,这样不仅仅不经济,而且后续维护上也会存在很多问题。为了解决这一问题,Java提供了容器框架来帮我们保存对象。本篇文章就与大家分享容器相关的知识,希望大家喜欢哦。

Java的容器

在Java中,我们想要保存对象可以使用很多种手段。我们之前了解过的数组就是其中之一。但是数组具有固定的尺寸,而通常来说,程序总是在运行时根据条件来创建对象,我们无法预知将要创建对象的个数以及类型,所以Java推出了容器类来解决这一问题。

List,Set,QueueMap。我们也称它们为集合类(Collection)。 Java使用泛型来实现容器类,例如我们要使用顺序表这一数据结构,Java提供了ArrayList和LinkedList两种实现类,ArrayList的实现就是基于数组的。比如我们要存储一组用户,在Java8之前的版本,我们就可以这样声明对象:List<User> users = new ArrayList<User>();。然后通过add方法来添加变量。

Java7及Java8的容器

Java7,它可以对泛型的目标类型进行推断。我们就可以这样声明这个对象List<User> users = new ArrayList<>();Java7中,编译器会根据变量声明时的泛型类型自动推断出实例化所用的泛型类型。但是它在创建泛型实例时的类型推断是有限制的:只有构造器的参数化类型在上下文中被显著的声明了,才可以使用类型推断,否则不行。比如:

List<String> list = new ArrayList<>();
list.add("A");// 由于addAll期望获得Collection<? extends String>类型的参数,因此下面的语句无法通过
list.addAll(new ArrayList<>());

Java8中,它支持两种泛型的目标类型推断:

1.支持通过方法上下文推断泛型目标类型

2.支持在方法调用链路当中,泛型类型推断传递到最后一个方法

上述程序可以更改如下:

//通过方法赋值的目标参数来自动推断泛型的类型
List<String> list = List.nil();
//通过前面方法参数类型推断泛型的类型
List.cons(42, List.nil());

Java容器的基本概念

Java容器类库是用来保存对象的,他有两种不同的概念:

Collection

  1. 。独立元素的序列,这些元素都服从一条或多条规则。

List

Set

  1. 以及

Queue

  1. 都是

Collection

  1. 的一种,

List

  1. 必须按照顺序保存元素,而

Set

  1. 不能有重复元素,

Queue

  1. 需要按照排队规则来确定对象的顺序。

Map

Map

  1. 是键值对类型,允许用户通过键来查找对象。

ArrayList

  1. 允许使用数字来查找值,

Hash表

  1. 允许我们使用另一个对象来查找某个对象。

Collection接口概括了序列的概念,即存放一组对象的方式。ArrayList,HashSet等具体类均实现了Collection接口或Collection接口的子接口(List接口和Set接口等)。

Collection接口的定义如下:

public interface Collection<E> extends Iterable<E> {

    int size();

    boolean isEmpty();

    boolean contains(Object o);

    Iterator<E> iterator();

    Object[] toArray();

    <T> T[] toArray(T[] a);

    boolean add(E e);

    boolean remove(Object o);

    boolean containsAll(Collection<?> c);

    boolean addAll(Collection<? extends E> c);

    boolean removeAll(Collection<?> c);

    boolean retainAll(Collection<?> c);

    void clear();

    boolean equals(Object o);

    int hashCode();
}

Iterable接口,实现这个接口的类可以使用迭代器以及foreach语法进行遍历。

size, isEmpty, contains, iterator, toArray, add, remove, containAll, addAll, removeAll, clear方法分别表示获取这个Collection类型的对象的元素个数,是否为空,是否包含某个元素,获取迭代器,转换为数组,增加元素,删除元素,某个Collection对象是否为它的子集以及进行取差集和清空操作。

java.utils包中的ArraysCollections类中还提供了很多实用的方法,如:

  • Arrays.asList()方法可以接受数组或逗号分隔的元素列表,并将其转化为一个

List

  • 对象。
  • Collections.addAll()方法接受一个

Collection

  • 对象和一个数组或以逗号分隔的列表将其加入到集合当中。
  • 等等

我们可以这样使用:

//使用asList方法生成list
List<String> keywords = Arrays.asList("hello", "thank", "you");
//我们要将其他元素加入到keywords容器中
Collections.addAll(keywords, "very", "much");

adddelete方法可能会引发改变数组尺寸的尝试,会在运行时得到Unsupported Operation错误。

如果要使用可以改变尺寸的List,我推荐大家在获取到asList()方法的输出后,再构造一个ArrayList。

迭代器

从之前的Collection接口中可以看出,任何容器类,都可以以某种方式插入、获取和删除元素。add()作为最基本的插入元素方法而get()则是基本取元素的方法。

但是如果我们仅仅使用get和add方法来进行元素操作,如果将一个类的方法实现了,如果想要将相同的代码用在其他容器类中就会遇到问题,那么我们如何解决这一问题呢?

迭代器模式。迭代器是一个对象,它的工作是遍历并选择序列中的对象。客户端不需要知道序列的底层架构。

Java的Iterator的定义如下:

public interface Iterator<E> {

    boolean hasNext();

    E next();

    void remove();
}

我们可以使用:

  1. next()方法来获取序列的下一个元素。
  2. hasNext()检查序列中是否还有元素。
  3. 使用remove()将迭代器新近返回的元素删除。比如我们要遍历一个容器:
List<String> keywords = new ArrayList<>();
keywords.add("hello");
keywords.add(0, "thank");
Iterator<String> iterator = keywords.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}

当然也可以使用foreach语法进行遍历:

List<String> keywords = new ArrayList<>();
keywords.add("hello");
keywords.add(0, "thank");
for (String keyword : keywords) {
    System.out.println(iterator.next());
}

Iterator还有一些功能更为强大的子类型,我会在下文予以介绍。在接下来的几节我会依次和大家介绍Java容器类中的几种接口。

List

List可以将元素维护在特定的序列中。List接口继承于Collection接口,并在此基础上添加了大量的方法,使得我们可以在List中间进行元素的插入和移动。

List有两种类型分别为:

  1. ArrayList,擅长随机访问元素,但是插入、删除元素较慢
  2. LinkedList,擅长插入、删除和移动元素,但是随机访问元素性能较低。

提示

学过数据结构的朋友们应该都知道,ArrayList是我们平时所使用的数组,而LinkedList就是链表。

数组的存储在内存空间中是连续的。所以在底层,我们可以通过每个元素所占的内存大小以及偏移量计算出每个元素所在的起始地址。但是在删除、插入元素时,由于需要保证数据存储位置的连续性,我们需要对它周围的元素进行搬移,而周围元素的搬移又会引起后续其他元素的搬移需求,所以最终所导致的移动操作很多。

而链表在内存中并不是连续存储的。它是一种逻辑顺序结构,每个链表存储的对象,都会存储下一个元素以及上一个元素的引用,通过引用来进行迭代。在删除、移动和插入时,我们不需要对元素的实际位置进行搬移,仅仅需要改变引用就可以了。但是由于它是逻辑上的顺序表,我们不能够静态的计算它的位置,只能一个一个的寻找,所以它的随机存取性能较低。

List接口的实例化对象可以使用Collection的所有方法:

List<String> keywords = new ArrayList<>();
List<String> oldKeywords = new LinkedList<>();
keywords.add("hello");
keywords.add(0, "thank");
oldKeywords.add("you");
oldKeywords.add(0, "very");
keywords.addAll(oldKeywords);
keywords.addAll(2, oldKeywords);
keywords.remove(3);
keywords.removeAll(oldKeywords);
List<String> subKeywords = keywords.subList(0, 1);
keywords.clear();

在使用时,我们会发现ArrayList类型的对象和LinkedList类型对象性能的不同。其中需要注意的是倒数第二行我们使用的subList函数是List接口独有的,它可以获取顺序表的一部分生成一个新的List。

ListIterator

ListIterator是更为强大的Iterator的子类型,但是它仅仅针对List的类进行访问。ListIterator可以进行双向移动、获取迭代器所处元素的前后元素的索引,还可以使用set()方法替换它访问过的最后一个元素。如:

List<String> keywords = new ArrayList<>();
keywords.add("hello");
keywords.add(0, "thank");
ListIterator<String> iterator = keywords.listIterator();
while (iterator.hasPrevious()) {
    System.out.println(iterator.previous());
}
while (iterator.hasNext()) {
    iterator.next();
    iterator.set("you");
}

Stack

Stack实现了栈数据结构,它是一种LIFO(后进先出)的容器。也就是我们先放进栈的元素,在使用时会先获取到最后放入的元素。

Stack<String> stack = new Stack<>();
stack.push("hello");
stack.push("thank");
while (!stack.empty()) {
    System.out.println(stack.pop());
}

在运行上述程序时,我们会发现最后的输出是thank, hello。这就体现了栈的LIFO特性。

我们可以使用栈这种数据结构做许多事情,如函数递归调用的现场维护就是使用栈来实现的。

Set

Set是一种不保存重复元素的数据结构。如果我们将多个相同元素放入Set中,它仅仅会保存一个。使用Set很适合进行查找操作,Java中提供了一个HashSet类,它的查找速度很快,适合用作快速查找。

在使用时与其他Collection的使用类似:

Set<String> keywords = new HashSet<>();
keywords.add("hello");
keywords.add("thank");
keywords.add("u");
keywords.add("thank");
keywords.add("u");
keywords.add("very");
keywords.add("much");
System.out.println(keywords);

我们在set中加入了一系列的词汇,其中有一些重复词汇,但是在实际输出时我们会发现,并不存在那么多的元素,而仅仅打印不重复元素。

Set有多种实现:

  1. HashSet,使用了散列方式进行存储。
  2. TreeSet,将元素存储在红黑数当中。它会对集合元素进行排序。
  3. LinkedHashSet,使用链表和哈希表来实现Set。

提示

具体的实现我们可以在数据结构的教程中深入了解,在这里我只与大家分享该如何在工程中选取数据结构。比如我们需要获取一个排好序的数列集合。我们就可以使用TreeSet,插入元素后,元素就会按照顺序存储。我们可以很方便的插入或删除元素同时保证排序质量。如果我们不需要排序,只需要保证插入和查找效率,那我们就可以仅仅使用HashSet来进行工作,我们可以很方便的通过它来测试元素的归属性,以及进行一系列的集合操作。

Map

Map可以将一个对象映射到另一个对象。在工程上,它是十分重要的数据结构。比如我们有一系列用户分组对象它保存了用户分组的信息,我们经常需要通过用户分组对象获取这个分组的所有用户。如果我们仅仅通过List进行存储,在查找时的工作量是很大的。因为我们需要从头开始遍历List,判断每个元素是否属于这一分组,但是引入Map后就简单许多了,我们可以将一个对象映射到另一个对象上,所以可以这样实现:

Map<Department, List<User>> departmentUsersMap = new HashMap<>();
departmentUsersMap.put(department1, users1);
departmentUsersMap.put(department2, users2);
//在获取时
List<User> departmentUser = departmentUsersMap.get(department);

提示

这次我们第一次用到了多维的实现,Map中嵌套List,事实上容器的嵌套层次是可以很深的。我们甚至将在Map中的List再嵌套一个Set。但是我们使用何种数据结构,要取决于我们程序的需求,我们数据结构的组合选择需要最大程度的满足我们的需求并尽可能地提高程序的效率。

Map数据结构除了上述映射获取功能以外,还可以获取键、值或键值对的集合,分别使用keySet, value以及entrySet。比如我们要遍历map:

Map<Department, List<User>> departmentUsersMap = new HashMap<>();
departmentUsersMap.put(department1, users1);
departmentUsersMap.put(department2, users2);
for (Map.Entry<String, String> departmentEntry : departmentUsersMap.entrySet()) {
    System.out.println(String.format("key:%s value:%s", departmentEntry.getKey().toString(), departmentEntry.getValue()
            .toString()));
}