集合

Java集合简介

集合就是“由若干个确定的元素所构成的整体”。

在Java中,如果一个Java对象可以在内部持有若干其他Java对象,并对外提供访问接口,我们把这种Java对象称为集合。

数组有如下限制:

  • 数组初始化后大小不可变;
  • 数组只能按索引顺序存取

Java标准库自带的java.util包提供了集合类:Collection,它是除Map外所有其他集合类的根接口。Java的java.util包主要提供了以下三种类型的集合:

  • List:一种有序列表的集合,例如,按索引排列的StudentList
  • Set:一种保证没有重复元素的集合,例如,所有无重复名称的StudentSet
  • Map:一种通过键值(key-value)查找的映射表集合,例如,根据Studentname查找对应StudentMap

Java集合的设计有几个特点:一是实现了接口和实现类相分离,例如,有序表的接口是List,具体的实现类有ArrayListLinkedList等,二是支持泛型,我们可以限制在一个集合中只能放入同一种数据类型的元素,例如:

List<String> list = new ArrayList<>(); // 只能放入String类型

最后,Java访问集合总是通过统一的方式——迭代器(Iterator)来实现,它最明显的好处在于无需知道集合内部元素是按什么方式存储的。

由于Java的集合设计非常久远,中间经历过大规模改进,我们要注意到有一小部分集合类是遗留类,不应该继续使用:

  • Hashtable:一种线程安全的Map实现;
  • Vector:一种线程安全的List实现;
  • Stack:基于Vector实现的LIFO的栈。

还有一小部分接口是遗留接口,也不应该继续使用:

  • Enumeration<E>:已被Iterator<E>取代。

小结

Java的集合类定义在java.util包中,支持泛型,主要提供了3种集合类,包括ListSetMap。Java集合使用统一的Iterator遍历,尽量不要使用遗留接口。

使用List

在集合类中,List是最基础的一种集合:它是一种有序列表。

List的行为和数组几乎完全相同:List内部按照放入元素的先后顺序存放,每个元素都可以通过索引确定自己的位置,List的索引和数组一样,从0开始。

在实际应用中,需要增删元素的有序列表,我们使用最多的是ArrayList。实际上,ArrayList在内部使用了数组来存储所有元素。

考察List<E>接口,可以看到几个主要的接口方法:

  • 在末尾添加一个元素:void add(E e)
  • 在指定索引添加一个元素:void add(int index, E e)
  • 删除指定索引的元素:int remove(int index)
  • 删除某个元素:int remove(Object e)
  • 获取指定索引的元素:E get(int index)
  • 获取链表大小(包含元素的个数):int size()
ArrayListLinkedList
获取指定元素速度很快需要从头开始查找元素
添加元素到末尾速度很快速度很快
在指定位置添加/删除需要移动元素不需要移动元素
内存占用较大

通常情况下,我们总是优先使用ArrayList。

List的特点

使用List时,我们要关注List接口的规范。List接口允许我们添加重复的元素,即List内部的元素可以重复。
List还允许添加null

创建List

除了使用ArrayListLinkedList,我们还可以通过List接口提供的of()方法,根据给定元素快速创建List

List<Integer> list = List.of(1, 2, 5);

遍历List

和数组类型相似,我们要遍历一个List,完全可以用for循环根据索引配合get(int)方法遍历:

public class Main {
    public static void main(String[] args) {
        List<String> list = List.of("apple", "pear", "banana");
        for (int i=0; i<list.size(); i++) {
            String s = list.get(i);
            System.out.println(s);
        }
    }
}

但这种方式并不推荐,一是代码复杂,二是因为get(int)方法只有ArrayList的实现是高效的,换成LinkedList后,索引越大,访问速度越慢。

所以我们要始终坚持使用迭代器Iterator来访问ListIterator本身也是一个对象,但它是由List的实例调用iterator()方法的时候创建的。Iterator对象知道如何遍历一个List,并且不同的List类型,返回的Iterator对象实现也是不同的,但总是具有最高的访问效率。
Iterator对象有两个方法:boolean hasNext()判断是否有下一个元素,E next()返回下一个元素。因此,使用Iterator遍历List代码如下:

public class Main {
    public static void main(String[] args) {
        List<String> list = List.of("apple", "pear", "banana");
        for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
            String s = it.next();
            System.out.println(s);
        }
    }
}

Java的for each循环本身就可以帮我们使用Iterator遍历。把上面的代码再改写如下:

public class Main {
    public static void main(String[] args) {
        List<String> list = List.of("apple", "pear", "banana");
        for (String s : list) {
            System.out.println(s);
        }
    }
}

List和Array转换

List变为Array有三种方法,
第一种是调用toArray()方法直接返回一个Object[]数组:

public class Main {
    public static void main(String[] args) {
        List<String> list = List.of("apple", "pear", "banana");
        Object[] array = list.toArray();
        for (Object s : array) {
            System.out.println(s);
        }
    }
}

这种方法会丢失类型信息,所以实际应用很少。

第二种方式是给toArray(T[])传入一个类型相同的ArrayList内部自动把元素复制到传入的Array中:

public class Main {
    public static void main(String[] args) {
        List<Integer> list = List.of(12, 34, 56);
        Integer[] array = list.toArray(new Integer[3]);
        for (Integer n : array) {
            System.out.println(n);
        }
    }
}

最后一种更简洁的写法是通过List接口定义的T[] toArray(IntFunction<T[]> generator)方法

Integer[] array = list.toArray(Integer[]::new);

Array变为List就简单多了,通过List.of(T...)方法最简单:

Integer[] array = { 1, 2, 3 };
List<Integer> list = List.of(array);

对于JDK 11之前的版本,可以使用Arrays.asList(T...)方法把数组转换成List

小结

  • List是按索引顺序访问的长度可变的有序表,优先使用ArrayList而不是LinkedList
  • 可以直接使用for each遍历List
  • List可以和Array相互转换。

编写equals方法

List还提供了boolean contains(Object o)方法来判断List是否包含某个指定元素。此外,int indexOf(Object o)方法可以返回某个元素的索引,如果元素不存在,就返回-1

因为List内部并不是通过==判断两个元素是否相等,而是使用equals()方法判断两个元素是否相等

编写equals

要正确使用Listcontains()indexOf()这些方法,放入的实例必须正确覆写equals()方法,否则,放进去的实例,查找不到。我们之所以能正常放入StringInteger这些对象,是因为Java标准库定义的这些类已经正确实现了equals()方法。

以Person对象为例,测试一下:

public class Main {
    public static void main(String[] args) {
        List<Person> list = List.of(
            new Person("Xiao Ming"),
            new Person("Xiao Hong"),
            new Person("Bob")
        );
        System.out.println(list.contains(new Person("Bob"))); // false
    }
}

class Person {
    String name;
    public Person(String name) {
        this.name = name;
    }
}

不出意外,虽然放入了new Person("Bob"),但是用另一个new Person("Bob")查询不到,原因就是Person类没有覆写equals()方法。

equals()方法要求我们必须满足以下条件:

  • 自反性(Reflexive):对于非nullx来说,x.equals(x)必须返回true
  • 对称性(Symmetric):对于非nullxy来说,如果x.equals(y)true,则y.equals(x)也必须为true
  • 传递性(Transitive):对于非nullxyz来说,如果x.equals(y)truey.equals(z)也为true,那么x.equals(z)也必须为true
  • 一致性(Consistent):对于非nullxy来说,只要xy状态不变,则x.equals(y)总是一致地返回true或者false
  • null的比较:即x.equals(null)永远返回false

我们总结一下equals()方法的正确编写方法:

  • 先确定实例“相等”的逻辑,即哪些字段相等,就认为实例相等;
  • instanceof判断传入的待比较的Object是不是当前类型,如果是,继续比较,否则,返回false
  • 对引用类型用Objects.equals()比较,对基本类型直接用==比较。
    使用Objects.equals()比较两个引用类型是否相等的目的是省去了判断null的麻烦。两个引用类型都是null时它们也是相等的。

小结

List中查找元素时,List的实现类通过元素的equals()方法比较两个元素是否相等,因此,放入的元素必须正确覆写equals()方法,Java标准库提供的StringInteger等已经覆写了equals()方法;

编写equals()方法可借助Objects.equals()判断。

如果不在List中查找元素,就不必覆写equals()方法。

使用Map

通过一个键去查询对应的值。使用List来实现存在效率非常低的问题,因为平均需要扫描一半的元素才能确定,而Map这种键值(key-value)映射表的数据结构,作用就是能高效通过key快速查找value(元素)。

Map来实现根据name查询某个Student的代码如下:

public class Main {
    public static void main(String[] args) {
        Student s = new Student("Xiao Ming", 99);
        Map<String, Student> map = new HashMap<>();
        map.put("Xiao Ming", s); // 将"Xiao Ming"和Student实例映射并关联
        Student target = map.get("Xiao Ming"); // 通过key查找并返回映射的Student实例
        System.out.println(target == s); // true,同一个实例
        System.out.println(target.score); // 99
        Student another = map.get("Bob"); // 通过另一个key查找
        System.out.println(another); // 未找到返回null
    }
}

class Student {
    public String name;
    public int score;
    public Student(String name, int score) {
        this.name = name;
        this.score = score;
    }
}

通过上述代码可知:Map<K, V>是一种键-值映射表,当我们调用put(K key, V value)方法时,就把keyvalue做了映射并放入Map。当我们调用V get(K key)时,就可以通过key获取到对应的value。如果key不存在,则返回null。和List类似,Map也是一个接口,最常用的实现类是HashMap

如果只是想查询某个key是否存在,可以调用boolean containsKey(K key)方法。

put()方法的签名是V put(K key, V value),如果放入的key已经存在,put()方法会返回被删除的旧的value,否则,返回null
始终牢记:Map中不存在重复的key,因为放入相同的key,只会把原有的key-value对应的value给替换掉。
此外,在一个Map中,虽然key不能重复,但value是可以重复的

遍历Map

Map来说,要遍历key可以使用for each循环遍历Map实例的keySet()方法返回的Set集合,它包含不重复的key的集合:

public class Main {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();
        map.put("apple", 123);
        map.put("pear", 456);
        map.put("banana", 789);
        for (String key : map.keySet()) {
            Integer value = map.get(key);
            System.out.println(key + " = " + value);
        }
    }
}

同时遍历keyvalue可以使用for each循环遍历Map对象的entrySet()集合,它包含每一个key-value映射:

public class Main {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();
        map.put("apple", 123);
        map.put("pear", 456);
        map.put("banana", 789);
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            String key = entry.getKey();
            Integer value = entry.getValue();
            System.out.println(key + " = " + value);
        }
    }
}

遍历Map时,不可假设输出的key是有序的!

小结

  • Map是一种映射表,可以通过key快速查找value

  • 可以通过for each遍历keySet(),也可以通过for each遍历entrySet(),直接获取key-value

  • 最常用的一种Map实现是HashMap

编写equals和hashCode

HashMap之所以能根据key直接拿到value,原因是它内部通过空间换时间的方法,用一个大数组存储所有value,并根据key直接计算出value应该存储在哪个索引。

我们放入Mapkey是字符串"a",但是,当我们获取Mapvalue时,传入的变量不一定就是放入的那个key对象。
换句话讲,两个key应该是内容相同,但不一定是同一个对象。测试代码如下:

public class Main {
    public static void main(String[] args) {
        String key1 = "a";
        Map<String, Integer> map = new HashMap<>();
        map.put(key1, 123);

        String key2 = new String("a");
        map.get(key2); // 123

        System.out.println(key1 == key2); // false
        System.out.println(key1.equals(key2)); // true
    }
}

因为在Map的内部,对key做比较是通过equals()实现的,这一点和List查找元素需要正确覆写equals()是一样的,即正确使用Map必须保证:作为key的对象必须正确覆写equals()方法。

我们经常使用String作为key,因为String已经正确覆写了equals()方法。但如果我们放入的key是一个自己写的类,就必须保证正确覆写了equals()方法。

通过key计算索引的方式就是调用key对象的hashCode()方法,它返回一个int整数。HashMap正是通过这个方法直接定位key对应的value的索引,继而直接返回value

因此,正确使用Map必须保证:

  • 作为key的对象必须正确覆写equals()方法,相等的两个key实例调用equals()必须返回true
  • 作为key的对象还必须正确覆写hashCode()方法,且hashCode()方法要严格遵循以下规范:
    • 如果两个对象相等,则两个对象的hashCode()必须相等;
    • 如果两个对象不相等,则两个对象的hashCode()尽量不要相等。

即对应两个实例ab

  • 如果ab相等,那么a.equals(b)一定为true,则a.hashCode()必须等于b.hashCode()
  • 如果ab不相等,那么a.equals(b)一定为false,则a.hashCode()b.hashCode()尽量不要相等。

在正确实现equals()的基础上,我们还需要正确实现hashCode(),即上述3个字段分别相同的实例,hashCode()返回的int必须相同:

public class Person {
    String firstName;
    String lastName;
    int age;

    @Override
    int hashCode() {
        int h = 0;
        h = 31 * h + firstName.hashCode();
        h = 31 * h + lastName.hashCode();
        h = 31 * h + age;
        return h;
    }
}

注意到String类已经正确实现了hashCode()方法,我们在计算PersonhashCode()时,反复使用31*h,这样做的目的是为了尽量把不同的Person实例的hashCode()均匀分布到整个int范围。

和实现equals()方法遇到的问题类似,如果firstNamelastNamenull,上述代码工作起来就会抛NullPointerException。为了解决这个问题,我们在计算hashCode()的时候,经常借助Objects.hash()来计算:

int hashCode() {
    return Objects.hash(firstName, lastName, age);
}

所以,编写equals()hashCode()遵循的原则是:
equals()用到的用于比较的每一个字段,都必须在hashCode()中用于计算;equals()中没有使用到的字段,绝不可放在hashCode()中计算。

另外注意,对于放入HashMapvalue对象,没有任何要求。

小结

要正确使用HashMap,作为key的类必须正确覆写equals()hashCode()方法;

一个类如果覆写了equals(),就必须覆写hashCode(),并且覆写规则是:

  • 如果equals()返回true,则hashCode()返回值必须相等;

  • 如果equals()返回false,则hashCode()返回值尽量不要相等。

实现hashCode()方法可以通过Objects.hashCode()辅助方法实现。

使用EnumMap

因为HashMap是一种通过对key计算hashCode(),通过空间换时间的方式,直接定位到value所在的内部数组的索引,因此,查找效率非常高。

如果作为key的对象是enum类型,那么,还可以使用Java集合库提供的一种EnumMap,它在内部以一个非常紧凑的数组存储value,并且根据enum类型的key直接定位到内部数组的索引,并不需要计算hashCode(),不但效率最高,而且没有额外的空间浪费。

我们以DayOfWeek这个枚举类型为例,为它做一个“翻译”功能:

public class Main {
    public static void main(String[] args) {
        Map<DayOfWeek, String> map = new EnumMap<>(DayOfWeek.class);
        map.put(DayOfWeek.MONDAY, "星期一");
        map.put(DayOfWeek.TUESDAY, "星期二");
        map.put(DayOfWeek.WEDNESDAY, "星期三");
        map.put(DayOfWeek.THURSDAY, "星期四");
        map.put(DayOfWeek.FRIDAY, "星期五");
        map.put(DayOfWeek.SATURDAY, "星期六");
        map.put(DayOfWeek.SUNDAY, "星期日");
        System.out.println(map);
        System.out.println(map.get(DayOfWeek.MONDAY));
    }
}

小结

如果Mapkeyenum类型,推荐使用EnumMap,既保证速度,也不浪费空间。

使用EnumMap的时候,根据面向抽象编程的原则,应持有Map接口。

TreeMap

还有一种Map,它在内部会对Key进行排序,这种Map就是SortedMap。注意到SortedMap是接口,它的实现类是TreeMap
Map
SortedMap保证遍历时以Key的顺序来进行排序。例如,放入的Key"apple""pear""orange",遍历的顺序一定是"apple""orange""pear",因为String默认按字母排序:

public class Main {
    public static void main(String[] args) {
        Map<String, Integer> map = new TreeMap<>();
        map.put("orange", 1);
        map.put("apple", 2);
        map.put("pear", 3);
        for (String key : map.keySet()) {
            System.out.println(key);
        }
        // apple, orange, pear
    }
}

使用TreeMap时,放入的Key必须实现Comparable接口。StringInteger这些类已经实现了Comparable接口,因此可以直接作为Key使用。作为Value的对象则没有任何要求。

如果作为Keyclass没有实现Comparable接口,那么,必须在创建TreeMap时同时指定一个自定义排序算法:

public class Main {
    public static void main(String[] args) {
        Map<Person, Integer> map = new TreeMap<>(new Comparator<Person>() {
            public int compare(Person p1, Person p2) {
                return p1.name.compareTo(p2.name);
            }
        });
        map.put(new Person("Tom"), 1);
        map.put(new Person("Bob"), 2);
        map.put(new Person("Lily"), 3);
        for (Person key : map.keySet()) {
            System.out.println(key);
        }
        // {Person: Bob}, {Person: Lily}, {Person: Tom}
        System.out.println(map.get(new Person("Bob"))); // 2
    }
}

class Person {
    public String name;
    Person(String name) {
        this.name = name;
    }
    public String toString() {
        return "{Person: " + name + "}";
    }
}

注意到Comparator接口要求实现一个比较方法,它负责比较传入的两个元素ab,如果a<b,则返回负数,通常是-1,如果a==b,则返回0,如果a>b,则返回正数,通常是1TreeMap内部根据比较结果对Key进行排序。

另外,注意到Person类并未覆写equals()hashCode(),因为TreeMap不使用equals()hashCode()

我们来看一个稍微复杂的例子:这次我们定义了Student类,并用分数score进行排序,高分在前:

public class Main {
    public static void main(String[] args) {
        Map<Student, Integer> map = new TreeMap<>(new Comparator<Student>() {
            public int compare(Student p1, Student p2) {
                return p1.score > p2.score ? -1 : 1;
            }
        });
        map.put(new Student("Tom", 77), 1);
        map.put(new Student("Bob", 66), 2);
        map.put(new Student("Lily", 99), 3);
        for (Student key : map.keySet()) {
            System.out.println(key);
        }
        System.out.println(map.get(new Student("Bob", 66))); // null?,相等时没有返回0
    }
}

class Student {
    public String name;
    public int score;
    Student(String name, int score) {
        this.name = name;
        this.score = score;
    }
    public String toString() {
        return String.format("{%s: score=%d}", name, score);
    }
}

直接借助Integer.compare(int, int)也可以返回正确的比较结果。

小结

  • SortedMap在遍历时严格按照Key的顺序遍历,最常用的实现类是TreeMap
  • 作为SortedMapKey必须实现Comparable接口,或者传入Comparator
  • 要严格按照compare()规范实现比较逻辑,否则,TreeMap将不能正常工作。

使用Properties

配置文件的特点是,它的Key-Value一般都是String-String类型的,因此我们完全可以用Map<String, String>来表示它。

因为配置文件非常常用,所以Java集合库提供了一个Properties来表示一组“配置”。由于历史遗留原因,Properties内部本质上是一个Hashtable,但我们只需要用到Properties自身关于读写配置的接口。

读取配置文件

Properties读取配置文件非常简单。Java默认配置文件以.properties为扩展名,每行以key=value表示,以#课开头的是注释。以下是一个典型的配置文件:

# setting.properties

last_open_file=/data/hello.txt
auto_save_interval=60

可以从文件系统读取这个.properties文件:

String f = "setting.properties";
Properties props = new Properties();
props.load(new java.io.FileInputStream(f));

String filepath = props.getProperty("last_open_file");
String interval = props.getProperty("auto_save_interval", "120");

可见,用Properties读取配置文件,一共有三步:

  • 创建Properties实例;
  • 调用load()读取文件;
  • 调用getProperty()获取配置。

调用getProperty()获取配置时,如果key不存在,将返回null。我们还可以提供一个默认值,这样,当key不存在的时候,就返回默认值。

也可以从classpath读取.properties文件,因为load(InputStream)方法接收一个InputStream实例,表示一个字节流,它不一定是文件流,也可以是从jar包中读取的资源流:

Properties props = new Properties();
props.load(getClass().getResourceAsStream("/common/setting.properties"));

试试从内存读取一个字节流:

import java.io.*;
import java.util.Properties;

public class Main {
    public static void main(String[] args) throws IOException {
        String settings = "# test" + "\n" + "course=Java" + "\n" + "last_open_date=2019-08-07T12:35:01";
        ByteArrayInputStream input = new ByteArrayInputStream(settings.getBytes("UTF-8"));
        Properties props = new Properties();
        props.load(input);

        System.out.println("course: " + props.getProperty("course"));
        System.out.println("last_open_date: " + props.getProperty("last_open_date"));
        System.out.println("last_open_file: " + props.getProperty("last_open_file"));
        System.out.println("auto_save: " + props.getProperty("auto_save", "60"));
    }
}

如果有多个.properties文件,可以反复调用load()读取,后读取的key-value会覆盖已读取的key-value:

Properties props = new Properties();
props.load(getClass().getResourceAsStream("/common/setting.properties"));
props.load(new FileInputStream("C:\\conf\\setting.properties"));

使用Set

Set用于存储不重复的元素集合,它主要提供以下几个方法:

  • 将元素添加进Set<E>boolean add(E e)
  • 将元素从Set<E>删除:boolean remove(Object e)
  • 判断是否包含元素:boolean contains(Object e)
public class Main {
    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        System.out.println(set.add("abc")); // true
        System.out.println(set.add("xyz")); // true
        System.out.println(set.add("xyz")); // false,添加失败,因为元素已存在
        System.out.println(set.contains("xyz")); // true,元素存在
        System.out.println(set.contains("XYZ")); // false,元素不存在
        System.out.println(set.remove("hello")); // false,删除失败,因为元素不存在
        System.out.println(set.size()); // 2,一共两个元素
    }
}

Set实际上相当于只存储key、不存储valueMap。我们经常用Set用于去除重复元素。

因为放入Set的元素和Mapkey类似,都要正确实现equals()hashCode()方法,否则该元素无法正确地放入Set

最常用的Set实现类是HashSet,实际上,HashSet仅仅是对HashMap的一个简单封装。

Set接口并不保证有序,而SortedSet接口则保证元素是有序的:

  • HashSet是无序的,因为它实现了Set接口,并没有实现SortedSet接口;
  • TreeSet是有序的,因为它实现了SortedSet接口。

使用TreeSet和使用TreeMap的要求一样,添加的元素必须正确实现Comparable接口,如果没有实现Comparable接口,那么创建TreeSet时必须传入一个Comparator对象。

小结

Set用于存储不重复的元素集合:

  • 放入HashSet的元素与作为HashMapkey要求相同;
  • 放入TreeSet的元素与作为TreeMapKey要求相同;
  • 利用Set可以去除重复元素;

遍历SortedSet按照元素的排序顺序遍历,也可以自定义排序算法。

使用Queue

队列(Queue)是一种经常使用的集合。Queue实际上是实现了一个先进先出(FIFO:First In First Out)的有序表。它和List的区别在于,List可以在任意位置添加和删除元素,而Queue只有两个操作:

  • 把元素添加到队列末尾;
  • 从队列头部取出元素。

在Java的标准库中,队列接口Queue定义了以下几个方法:

  • int size():获取队列长度;
  • boolean add(E)/boolean offer(E):添加元素到队尾;
  • E remove()/E poll():获取队首元素并从队列中删除;
  • E element()/E peek():获取队首元素但并不从队列中删除。

对于具体的实现类,有的Queue有最大队列长度限制,有的Queue没有。注意到添加、删除和获取队列元素总是有两个方法,这是因为在添加或获取元素失败时,这两个方法的行为是不同的。我们用一个表格总结如下:

throw Exception返回false或null
添加元素到队尾add(E e)boolean offer(E e)
取队首元素并删除E remove()E poll()
取队首元素但不删除E element()E peek()

注意:不要把null添加到队列中,否则poll()方法返回null时,很难确定是取到了null元素还是队列为空。

LinkedList即实现了List接口,又实现了Queue接口,但是,在使用的时候,如果我们把它当作List,就获取List的引用,如果我们把它当作Queue,就获取Queue的引用:

// 这是一个List:
List<String> list = new LinkedList<>();
// 这是一个Queue:
Queue<String> queue = new LinkedList<>();

小结

队列Queue实现了一个先进先出(FIFO)的数据结构:

  • 通过add()/offer()方法将元素添加到队尾;
  • 通过remove()/poll()从队首获取元素并删除;
  • 通过element()/peek()从队首获取元素但不删除。
    要避免把null添加到队列。

使用PriorityQueue

PriorityQueueQueue的区别在于,它的出队顺序与元素的优先级有关,对PriorityQueue调用remove()poll()方法,返回的总是优先级最高的元素。

要使用PriorityQueue,我们就必须给每个元素定义“优先级”。我们以实际代码为例,先看看PriorityQueue的行为:

public class Main {
    public static void main(String[] args) {
        Queue<String> q = new PriorityQueue<>();
        // 添加3个元素到队列:
        q.offer("apple");
        q.offer("pear");
        q.offer("banana");
        System.out.println(q.poll()); // apple
        System.out.println(q.poll()); // banana
        System.out.println(q.poll()); // pear
        System.out.println(q.poll()); // null,因为队列为空
    }
}

我们放入的顺序是"apple"、"pear"、"banana",但是取出的顺序却是"apple"、"banana"、"pear",这是因为从字符串的排序看,"apple“排在最前面,”pear"排在最后面。

因此,放入PriorityQueue的元素,必须实现Comparable接口,PriorityQueue会根据元素的排序顺序决定出队的优先级。

如果我们要放入的元素并没有实现Comparable接口怎么办?PriorityQueue允许我们提供一个Comparator对象来判断两个元素的顺序。我们以银行排队业务为例,实现一个PriorityQueue

public class Main {
    public static void main(String[] args) {
        Queue<User> q = new PriorityQueue<>(new UserComparator());
        // 添加3个元素到队列:
        q.offer(new User("Bob", "A1"));
        q.offer(new User("Alice", "A2"));
        q.offer(new User("Boss", "V1"));
        System.out.println(q.poll()); // Boss/V1
        System.out.println(q.poll()); // Bob/A1
        System.out.println(q.poll()); // Alice/A2
        System.out.println(q.poll()); // null,因为队列为空
    }
}

class UserComparator implements Comparator<User> {
    public int compare(User u1, User u2) {
        if (u1.number.charAt(0) == u2.number.charAt(0)) {
            // 如果两人的号都是A开头或者都是V开头,比较号的大小:
            return u1.number.compareTo(u2.number);
        }
        if (u1.number.charAt(0) == 'V') {
            // u1的号码是V开头,优先级高:
            return -1;
        } else {
            return 1;
        }
    }
}

class User {
    public final String name;
    public final String number;

    public User(String name, String number) {
        this.name = name;
        this.number = number;
    }

    public String toString() {
        return name + "/" + number;
    }
}

小结

PriorityQueue实现了一个优先队列:从队首获取元素时,总是获取优先级最高的元素。
PriorityQueue默认按元素比较的顺序排序(必须实现Comparable接口),也可以通过Comparator自定义排序算法(元素就不必实现Comparable接口)。

使用Deque

如果把条件放松一下,允许两头都进,两头都出,这种队列叫双端队列(Double Ended Queue),学名Deque

Java集合提供了接口Deque来实现一个双端队列,它的功能是:

  • 既可以添加到队尾,也可以添加到队首;
  • 既可以从队首获取,又可以从队尾获取。
QueueDeque
添加元素到队尾add(E e)/offer(E e)addLast(E e)/offerLast(E e)
取队首元素并删除E remove()/E poll()E removeFirst()/ E pollFirst()
取队首元素但不删除E element()/E peek()E getFirst()/E peekFirst()
添加元素到队首addFirst()/addLast()
取队尾元素并删除E removeLast()/E pollLast()
取队尾元素但不删除E getLast()/E peekLast()

Deque是一个接口,它的实现类有ArrayDequeLinkedList
我们发现LinkedList真是一个全能选手,它即是List,又是Queue,还是Deque。但是我们在使用的时候,总是用特定的接口来引用它,这是因为持有接口说明代码的抽象层次更高,而且接口本身定义的方法代表了特定的用途。

// 不推荐的写法:
LinkedList<String> d1 = new LinkedList<>();
d1.offerLast("z");
// 推荐的写法:
Deque<String> d2 = new LinkedList<>();
d2.offerLast("z");

小结

Deque实现了一个双端队列(Double Ended Queue),它可以:

  • 将元素添加到队尾或队首:addLast()/offerLast()/addFirst()/offerFirst()
  • 从队首/队尾获取元素并删除:removeFirst()/pollFirst()/removeLast()/pollLast()
  • 从队首/队尾获取元素但不删除:getFirst()/peekFirst()/getLast()/peekLast()
  • 总是调用xxxFirst()/xxxLast()以便与Queue的方法区分开;
  • 避免把null添加到队列。

使用Satck

栈(Stack)是一种后进先出(LIFO:Last In First Out)的数据结构。
Stack是这样一种数据结构:只能不断地往Stack中压入(push)元素,最后进去的必须最早弹出(pop)来:

Stack只有入栈和出栈的操作:

  • 把元素压栈:push(E)
  • 把栈顶的元素“弹出”:pop(E)
  • 取栈顶元素但不弹出:peek(E)

在Java中,我们用Deque可以实现Stack的功能:

  • 把元素压栈:push(E)/addFirst(E)
  • 把栈顶的元素“弹出”:pop(E)/removeFirst()
  • 取栈顶元素但不弹出:peek(E)/peekFirst()

为什么Java的集合类没有单独的Stack接口呢?因为有个遗留类名字就叫Stack,出于兼容性考虑,所以没办法创建Stack接口,只能用Deque接口来“模拟”一个Stack了。

当我们把Deque作为Stack使用时,注意只调用push()/pop()/peek()方法,不要调用addFirst()/removeFirst()/peekFirst()方法,这样代码更加清晰。

Stack的作用

Stack在计算机中使用非常广泛,JVM在处理Java方法调用的时候就会通过栈这种数据结构维护方法调用的层次

我们再来看一个Stack的用途:对整数进行进制的转换就可以利用栈。

计算中缀表达式

小结

栈(Stack)是一种后进先出(LIFO)的数据结构,操作栈的元素的方法有:

  • 把元素压栈:push(E)
  • 把栈顶的元素“弹出”:pop(E)
  • 取栈顶元素但不弹出:peek(E)
  • 在Java中,我们用Deque可以实现Stack的功能,注意只调用push()/pop()/peek()方法,避免调用Deque的其他方法。
    最后,不要使用遗留类Stack

使用Iterator

编译器把for each循环通过Iterator改写为了普通的for循环:

for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
     String s = it.next();
     System.out.println(s);
}

我们把这种通过Iterator对象遍历集合的模式称为迭代器。

Iterator对象是集合对象自己在内部创建的,它自己知道如何高效遍历内部的数据集合,调用方则获得了统一的代码,编译器才能把标准的for each循环自动转换为Iterator遍历。

如果我们自己编写了一个集合类,想要使用for each循环,只需满足以下条件:

  • 集合类实现Iterable接口,该接口要求返回一个Iterator对象;
  • Iterator对象迭代集合内部数据。

这里的关键在于,集合类通过调用iterator()方法,返回一个Iterator对象,这个对象必须自己知道如何遍历该集合。
一个简单的Iterator示例如下,它总是以倒序遍历集合:

import java.util.*;

public class Main {
    public static void main(String[] args) {
        ReverseList<String> rlist = new ReverseList<>();
        rlist.add("Apple");
        rlist.add("Orange");
        rlist.add("Pear");
        for (String s : rlist) {
            System.out.println(s);
        }
    }
}

class ReverseList<T> implements Iterable<T> {

    private List<T> list = new ArrayList<>();

    public void add(T t) {
        list.add(t);
    }

    @Override
    public Iterator<T> iterator() {
        return new ReverseIterator(list.size());
    }

    class ReverseIterator implements Iterator<T> {
        int index;

        ReverseIterator(int index) {
            this.index = index;
        }

        @Override
        public boolean hasNext() {
            return index > 0;
        }

        @Override
        public T next() {
            index--;
            return ReverseList.this.list.get(index);
        }
    }
}

在编写Iterator的时候,我们通常可以用一个内部类来实现Iterator接口,这个内部类可以直接访问对应的外部类的所有字段和方法。例如,上述代码中,内部类ReverseIterator可以用ReverseList.this获得当前外部类的this引用,然后,通过这个this引用就可以访问ReverseList的所有字段和方法。

小结

Iterator是一种抽象的数据访问模型。使用Iterator模式进行迭代的好处有:

  • 对任何集合都采用同一种访问模型;
  • 调用者对集合内部结构一无所知;
  • 集合类返回的Iterator对象知道如何迭代。

Java提供了标准的迭代器模型,即集合类实现java.util.Iterable接口,返回java.util.Iterator实例。

使用Collections

Collections是JDK提供的工具类,同样位于java.util包中。它提供了一系列静态方法,能更方便地操作各种集合。

addAll()方法可以给一个Collection类型的集合添加若干元素。因为方法签名是Collection,所以我们可以传入ListSet等各种集合类型。

创建空集合

Collections提供了一系列方法来创建空集合:

  • 创建空ListList<T> emptyList()
  • 创建空MapMap<K, V> emptyMap()
  • 创建空SetSet<T> emptySet()

要注意到返回的空集合是不可变集合,无法向其中添加或删除元素。
此外,也可以用各个集合接口提供的of(T...)方法创建空集合。例如,以下创建空List的两个方法是等价的:

List<String> list1 = List.of();
List<String> list2 = Collections.emptyList();

创建单元素集合

Collections提供了一系列方法来创建一个单元素集合:

  • 创建一个元素的ListList<T> singletonList(T o)
  • 创建一个元素的MapMap<K, V> singletonMap(K key, V value)
  • 创建一个元素的SetSet<T> singleton(T o)

要注意到返回的单元素集合也是不可变集合,无法向其中添加或删除元素。

实际上,使用List.of(T...)更方便,因为它既可以创建空集合,也可以创建单元素集合,还可以创建任意个元素的集合:

排序

Collections可以对List进行排序。因为排序会直接修改List元素的位置,因此必须传入可变List

public class Main {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("apple");
        list.add("pear");
        list.add("orange");
        // 排序前:
        System.out.println(list);
        Collections.sort(list);
        // 排序后:
        System.out.println(list);
    }
}

洗牌

Collections提供了洗牌算法,即传入一个有序的List,可以随机打乱List内部元素的顺序,效果相当于让计算机洗牌:

public class Main {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        for (int i=0; i<10; i++) {
            list.add(i);
        }
        // 洗牌前:
        System.out.println(list);
        Collections.shuffle(list);
        // 洗牌后:
        System.out.println(list);
    }
}

不可变集合

Collections还提供了一组方法把可变集合封装成不可变集合:

  • 封装成不可变ListList<T> unmodifiableList(List<? extends T> list)
  • 封装成不可变SetSet<T> unmodifiableSet(Set<? extends T> set)
  • 封装成不可变MapMap<K, V> unmodifiableMap(Map<? extends K, ? extends V> m)

这种封装实际上是通过创建一个代理对象,拦截掉所有修改方法实现的。我们来看看效果:

public class Main {
    public static void main(String[] args) {
        List<String> mutable = new ArrayList<>();
        mutable.add("apple");
        mutable.add("pear");
        // 变为不可变集合:
        List<String> immutable = Collections.unmodifiableList(mutable);
        immutable.add("orange"); // UnsupportedOperationException!
    }
}

然而,继续对原始的可变List进行增删是可以的,并且,会直接影响到封装后的“不可变”List

因此,如果我们希望把一个可变List封装成不可变List,那么,返回不可变List后,最好立刻扔掉可变List的引用,这样可以保证后续操作不会意外改变原始对象,从而造成“不可变”List变化了。

线程安全集合

Collections还提供了一组方法,可以把线程不安全的集合变为线程安全的集合:

  • 变为线程安全的ListList<T> synchronizedList(List<T> list)
  • 变为线程安全的SetSet<T> synchronizedSet(Set<T> s)
  • 变为线程安全的MapMap<K,V> synchronizedMap(Map<K,V> m)

小结

Collections类提供了一组工具方法来方便使用集合类:

  • 创建空集合;
  • 创建单元素集合;
  • 创建不可变集合;
  • 排序/洗牌等操作。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值