《Java 编程的逻辑》笔记——第9章 列表和队列

声明:

本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

9.1 剖析 ArrayList

第 8 章介绍泛型的时候,我们自己实现了一个简单的动态数组容器类 DynaArray,本节将介绍 Java 中真正的动态数组容器类 ArrayList。本节会介绍它的基本用法、迭代操作、实现的一些接口(Collection、 List 和 RandAcces),最后分析它的特点。

9.1.1 基本用法

新建 ArrayList

ArrayList 是一个泛型容器,新建 ArrayList 需要实例化泛型参数,比如:

ArrayList<Integer> intList = new ArrayList<Integer>();
ArrayList<String> strList = new ArrayList<String>();

添加元素

add 方法添加元素到末尾

ArrayList<Integer> intList = new ArrayList<Integer>();
intList.add(123);
intList.add(456);
ArrayList<String> strList = new ArrayList<String>();
strList.add("老马");
strList.add("编程");

长度方法

判断是否为空

public boolean isEmpty()

获取长度

public int size()

访问指定位置的元素

public E get(int index)

如:

ArrayList<String> strList = new ArrayList<String>();
strList.add("老马");
strList.add("编程");
for(int i=0; i<strList.size(); i++){
    
    
    System.out.println(strList.get(i));
}

查找元素

public int indexOf(Object o)

如果找到,返回索引位置,否则返回 -1。

从后往前找

public int lastIndexOf(Object o)

是否包含指定元素

public boolean contains(Object o)

相同的依据是 equals 方法返回 true。如果传入的元素为 null,则找 null 的元素。

删除元素

删除指定位置的元素

public E remove(int index)

返回值为被删对象。

删除指定对象

public boolean remove(Object o)

与 indexOf 一样,比较的依据的是 equals 方法,如果 o 为 null,则删除值为 null 的元素。另外,remove 只删除第一个相同的对象,也就是说,即使 ArrayList 中有多个与 o 相同的元素,也只会删除第一个。返回值为 boolean 类型,表示是否删除了元素。

删除所有元素

public void clear() 

插入元素

在指定位置插入元素

public void add(int index, E element)

index 为 0 表示插入最前面,index 为 ArrayList 的长度表示插到最后面。

修改元素

修改指定位置的元素内容

public E set(int index, E element) 

9.1.2 基本原理

9.1.2.1 内部组成

可以看出,ArrayList 的基本用法是比较简单的,它的基本原理也是比较简单的,原理与我们在前面几节介绍的 DynaArray 类似,内部有一个数组 elementData,一般会有一些预留的空间,有一个整数 size 记录实际的元素个数,如下所示:

private transient Object[] elementData;
private int size;

我们暂时可以忽略 transient 这个关键字。各种 public 方法内部操作的基本都是这个数组和这个整数,elementData 会随着实际元素个数的增多而重新分配,而 size 则始终记录实际的元素个数。

9.1.2.2 add 方法

虽然基本思路是简单的,但内部代码有一些比较晦涩,我们来看下 add 方法的代码:

public boolean add(E e) {
    
    
    ensureCapacityInternal(size + 1); 
    elementData[size++] = e;
    return true;
}

它首先调用 ensureCapacityInternal 确保数组容量是够的,ensureCapacityInternal 的代码是:

private void ensureCapacityInternal(int minCapacity) {
    
    
    if (elementData == EMPTY_ELEMENTDATA) {
    
    
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }

    ensureExplicitCapacity(minCapacity);
}

它先判断数组是不是空的,如果是空的,则首次至少要分配的大小为 DEFAULT_CAPACITY,DEFAULT_CAPACITY 的值为 10,接下来调用 ensureExplicitCapacity,代码为:

private void ensureExplicitCapacity(int minCapacity) {
    
    
    modCount++;

    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

modCount++ 是什么意思呢?modCount 表示内部的修改次数,modCount++ 当然就是增加修改次数,为什么要记录修改次数呢?我们待会解释。

如果需要的长度大于当前数组的长度,则调用 grow 方法,其主要代码为:

private void grow(int minCapacity) {
    
    
    int oldCapacity = elementData.length;
    // 右移一位相当于除2,所以,newCapacity相当于oldCapacity的1.5倍
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    // 如果拓展1.5倍还是小于minCapacity,就拓展为minCapacity
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    elementData = Arrays.copyOf(elementData, newCapacity);
}

9.1.2.3 remove方法

我们再来看 remove 方法的代码:

public E remove(int index) {
    
    
    rangeCheck(index);

    modCount++;
    E oldValue = elementData(index);

    int numMoved = size - index - 1; // 计算要移动的元素个数
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // 将size减1,同时释放引用以便原对象被垃圾回收

    return oldValue;
}

它也增加了 modCount,然后计算要移动的元素个数,从 index 往后的元素都往前移动一位,实际调用 System.arraycopy 方法移动元素。elementData[–size] = null; 这行代码将 size 减一,同时将最后一个位置设为 null,设为 null 后就不再引用原来对象,如果原来对象也不再被其他对象引用,就可以被垃圾回收。

其他方法大多是比较简单的,我们就不赘述了。总体而言,内部操作要考虑各种情况,代码有一些晦涩复杂,但接口一般都是简单直接的,这就是使用容器类的好处了,这也是计算机程序中的基本思维方式,封装复杂操作,提供简单接口

9.1.3 迭代

9.1.3.1 foreach 用法

理解了 ArrayList 的基本用法和原理,接下来,我们来看一个常见的操作:迭代,比如说,循环打印 ArrayList 中的每个元素,ArrayList 支持 foreach 语法,比如:

ArrayList<Integer> intList = new ArrayList<Integer>();
intList.add(123);
intList.add(456);
intList.add(789);
for(Integer a : intList){
    
    
    System.out.println(a);
}

当然,这种循环也可以使用如下代码实现:

for(int i=0; i<intList.size(); i++){
    
    
    System.out.println(intList.get(i));
}

不过,foreach 看上去更为简洁,而且,它适用于各种容器,更为通用

这种 foreach 语法背后是怎么实现的呢?其实,编译器会将它转换为类似如下代码:

Iterator<Integer> it = intList.iterator();
while(it.hasNext()){
    
    
    System.out.println(it.next());
}

接来下,我们解释一下其中的代码。

9.1.3.2 迭代器接口

ArrayList 实现了 Iterable 接口,Iterable 表示可迭代,它的定义为:

public interface Iterable<T> {
    
    
    Iterator<T> iterator();
}

定义很简单,就是要求实现 iterator 方法。iterator 方法的声明为:

public Iterator<E> iterator()

它返回一个实现了 Iterator 接口的对象,Iterator 接口的定义为:

public interface Iterator<E> {
    
    
    boolean hasNext();
    E next();
    void remove();
}

hasNext() 判断是否还有元素未访问,next() 返回下一个元素,remove() 删除最后返回的元素,只读访问的基本模式就类似于:

Iterator<Integer> it = intList.iterator();
while(it.hasNext()){
    
    
    System.out.println(it.next());
}

我们待会再看迭代中间要删除元素的情况。

只要对象实现了 Iterable 接口,就可以使用 foreach 语法,编译器会转换为调用 Iterable 和 Iterator 接口的方法

初次见到 Iterable 和 Iterator,可能会比较容易混淆,我们再澄清一下:

  • Iterable 表示对象可以被迭代,它有一个方法 iterator(),返回 Iterator 对象,实际通过 Iterator 接口的方法进行遍历。
  • 如果对象实现了 Iterable,就可以使用 foreach 语法。
  • 类可以不实现 Iterable,也可以创建 Iterator 对象。

9.1.3.3 ListIterator

除了 iterator(),ArrayList 还提供了两个返回 Iterator 接口的方法:

public ListIterator<E> listIterator()
public ListIterator<E> listIterator(int index)

ListIterator 扩展了 Iterator 接口,增加了一些方法,向前遍历、添加元素、修改元素、返回索引位置等,添加的方法有:

public interface ListIterator<E> extends Iterator<E> {
    
    
    boolean hasPrevious();
    E previous();
    int nextIndex();
    int previousIndex();
    void set(E e);
    void add(E e);
}

listIterator() 方法返回的迭代器从 0 开始,而 listIterator(int index) 方法返回的迭代器从指定位置 index 开始,比如,从末尾往前遍历,代码为:

public void reverseTraverse(List<Integer> list){
    
    
    ListIterator<Integer> it = list.listIterator(list.size());
    while(it.hasPrevious()){
    
    
        System.out.println(it.previous());
    }
}

9.1.3.4 迭代的陷阱

关于迭代器,有一种常见的误用,就是在迭代的中间调用容器的删除方法,比如要删除一个整数 ArrayList 中所有小于 100 的数,直觉上,代码可以这么写:

public void remove(ArrayList<Integer> list){
    
    
    for(Integer a : list){
    
    
        if(a<=100){
    
    
            list.remove(a);
        }
    }
}

但,运行时会抛出异常:

java.util.ConcurrentModificationException

发生了并发修改异常,为什么呢?迭代器内部会维护一些索引位置相关的数据,要求在迭代过程中,容器不能发生结构性变化,否则这些索引位置就失效了。所谓结构性变化就是添加、插入和删除元素,只是修改元素内容不算结构性变化。

如何避免异常呢?可以使用迭代器的 remove 方法,如下所示:

public static void remove(ArrayList<Integer> list){
    
    
    Iterator<Integer> it = list.iterator();
    while(it.hasNext()){
    
    
        if(it.next()<=100){
    
    
            it.remove();
        }
    }
}

迭代器如何知道发生了结构性变化,并抛出异常?它自己的 remove 方法为何又可以使用呢?我们需要看下迭代器的工作原理。

9.1.3.5 迭代器实现的原理

我们来看下 ArrayList 中 iterator 方法的实现,代码为:

public Iterator<E> iterator() {
    
    
    return new Itr();
}

新建了一个 Itr 对象,Itr 是一个成员内部类,实现了 Iterator 接口,声明为:

private class Itr implements Iterator<E>

它有三个实例成员变量:

int cursor;       // 下一个要返回的元素位置
int lastRet = -1; // 最后一个返回的索引位置,如果没有,为-1
int expectedModCount = modCount;

cursor 表示下一个要返回的元素位置,lastRet 表示最后一个返回的索引位置,expectedModCount 表示期望的修改次数,初始化为外部类当前的修改次数 modCount,回顾一下,成员内部类可以直接访问外部类的实例变量。

每次发生结构性变化的时候 modCount 都会增加,而每次迭代器操作的时候都会检查 expectedModCount 是否与 modCount 相同,这样就能检测出结构性变化。

我们来具体看下,它是如何实现 Iterator 接口中的每个方法的,先看 hasNext(),代码为:

public boolean hasNext() {
    
    
    return cursor != size;
}

cursor 与 size 比较,比较直接,看 next() 方法:

public E next() {
    
    
    checkForComodification();
    int i = cursor;
    if (i >= size)
        throw new NoSuchElementException();
    Object[] elementData = ArrayList.this.elementData;
    if (i >= elementData.length)
        throw new ConcurrentModificationException();
    cursor = i + 1;
    return (E) elementData[lastRet = i];
}

首先调用了 checkForComodification,它的代码为:

final void checkForComodification() {
    
    
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

所以,next() 前面部分主要就是在检查是否发生了结构性变化,如果没有变化,就更新 cursor 和 lastRet 的值,以保持其语义,然后返回对应的元素。

remove 的代码为:

public void remove() {
    
    
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification();

    try {
    
    
        ArrayList.this.remove(lastRet);
        cursor = lastRet;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
    
    
        throw new ConcurrentModificationException();
    }
}

它调用了 ArrayList 的 remove 方法,但同时更新了 cursor, lastRet 和 expectedModCount 的值,所以它可以正确删除。

不过,需要注意的是,调用 remove 方法前必须先调用 next。比如,通过迭代器删除所有元素,直觉上,可以这么写:

public static void removeAll(ArrayList<Integer> list){
    
    
    Iterator<Integer> it = list.iterator();
    while(it.hasNext()){
    
    
        it.remove();    
    }
}

实际运行,会抛出异常:

java.lang.IllegalStateException

正确写法是:

public static void removeAll(ArrayList<Integer> list){
    
    
    Iterator<Integer> it = list.iterator();
    while(it.hasNext()){
    
    
        it.next();
        it.remove();
    }
}

当然,如果只是要删除所有元素,ArrayList 有现成的方法 clear()。

listIterator() 的实现使用了另一个内部类 ListItr,它继承自 Itr,基本思路类似,我们就不赘述了。

9.1.3.6 迭代器的好处

为什么要通过迭代器这种方式访问元素呢?直接使用 size()/get(index) 语法不也可以吗?在一些场景下,确实没有什么差别,两者都可以。不过,foreach 语法更为简洁一些,更重要的是,迭代器语法更为通用,它适用于各种容器类。

此外,迭代器表示的是一种关注点分离的思想,将数据的实际组织方式与数据的迭代遍历相分离,是一种常见的设计模式。需要访问容器元素的代码只需要一个 Iterator 接口的引用,不需要关注数据的实际组织方式,可以使用一致和统一的方式进行访问。

而提供 Iterator 接口的代码了解数据的组织方式,可以提供高效的实现。在 ArrayList 中, size/get(index) 语法与迭代器性能是差不多的,但在后续介绍的其他容器中,则不一定,比如 LinkedList,迭代器性能就要高很多。

从封装的思路上讲,迭代器封装了各种数据组织方式的迭代操作,提供了简单和一致的接口

9.1.4 ArrayList 实现的接口

Java 的各种容器类有一些共性的操作,这些共性以接口的方式体现,我们刚刚介绍的 Iterable 接口就是,此外,ArrayList 还实现了三个主要的接口 Collection, List 和 RandomAccess,我们逐个来看下。

9.1.4.1 Collection

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();
}

这些方法中,除了两个 toArray 方法和几个 xxxAll() 方法外,其他我们已经介绍过了。

这几个 xxxAll() 方法的含义基本也是可以顾名思义的,addAll 添加,removeAll 删除,containsAll 检查是否包含了参数容器中的所有元素,只有全包含才返回 true,retainAll 只保留参数容器中的元素,其他元素会进行删除。

有一个抽象类 AbstractCollection 对这几个方法都提供了默认实现,实现的方式就是利用迭代器方法逐个操作,比如说,我们看 removeAll 方法,代码为:

public boolean removeAll(Collection<?> c) {
    
    
    boolean modified = false;
    Iterator<?> it = iterator();
    while (it.hasNext()) {
    
    
        if (c.contains(it.next())) {
    
    
            it.remove();
            modified = true;
        }
    }
    return modified;
}

代码比较简单,就不解释了。ArrayList 继承了 AbstractList,而 AbstractList 又继承了 AbstractCollection,ArrayList 对其中一些方法进行了重写,以提供更为高效的实现,具体我们就不介绍了。

关于 toArray 方法,我们待会再介绍。

9.1.4.2 List

List 表示有顺序或位置的数据集合,它扩展了 Collection,增加的主要方法有:

boolean addAll(int index, Collection<? extends E> c);
E get(int index);
E set(int index, E element);
void add(int index, E element);
E remove(int index);
int indexOf(Object o);
int lastIndexOf(Object o);
ListIterator<E> listIterator();
ListIterator<E> listIterator(int index);
List<E> subList(int fromIndex, int toIndex);

这些方法都与位置有关,容易理解,就不介绍了。

9.1.4.3 RandomAccess

RandomAccess 的定义为:

public interface RandomAccess {
    
    
}

没有定义任何代码。这有什么用呢?这种没有任何代码的接口在 Java 中被称之为标记接口,用于声明类的一种属性。

这里,实现了 RandomAccess 接口的类表示可以随机访问,可随机访问就是具备类似数组那样的特性,数据在内存是连续存放的,根据索引值就可以直接定位到具体的元素,访问效率很高。下节我们会介绍 LinkedList,它就不能随机访问。

有没有声明 RandomAccess 有什么关系呢?主要用于一些通用的算法代码中,它可以根据这个声明而选择效率更高的实现。比如说,Collections 类中有一个方法 binarySearch,在 List 中进行二分查找,它的实现代码就根据 list 是否实现了 RandomAccess 而采用不同的实现机制,如下所示:

public static <T>
int binarySearch(List<? extends Comparable<? super T>> list, T key) {
    
    
    if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
        return Collections.indexedBinarySearch(list, key);
    else
        return Collections.iteratorBinarySearch(list, key);
}

9.1.5 ArrayList 的其他方法

9.1.5.1 构造方法

ArrayList 还有两个构造方法

public ArrayList(int initialCapacity)
public ArrayList(Collection<? extends E> c)

第一个方法以指定的大小 initialCapacity 初始化内部的数组大小,代码为:

this.elementData = new Object[initialCapacity];

在事先知道元素长度的情况下,或者,预先知道长度上限的情况下,使用这个构造方法可以避免重新分配和拷贝数组。

第二个构造方法以一个已有的 Collection 构建,数据会新拷贝一份。

9.1.5.2 与数组的相互转换

ArrayList 中有两个方法可以返回数组

public Object[] toArray()
public <T> T[] toArray(T[] a) 

第一个方法返回是 Object 数组,代码为:

public Object[] toArray() {
    
    
    return Arrays.copyOf(elementData, size);
}

第二个方法返回对应类型的数组,如果参数数组长度足以容纳所有元素,就使用该数组,否则就新建一个数组,比如:

ArrayList<Integer> intList = new ArrayList<Integer>();
intList.add(123);
intList.add(456);
intList.add(789);

Integer[] arrA = new Integer[3];
intList.toArray(arrA);
Integer[] arrB = intList.toArray(new Integer[0]);

System.out.println(Arrays.equals(arrA, arrB));

输出为 true,表示两种方式都是可以的。

Arrays 中有一个静态方法 asList 可以返回对应的 List,如下所示:

Integer[] a = {
    
    1,2,3};
List<Integer> list = Arrays.asList(a);

需要注意的是,这个方法返回的 List,它的实现类并不是本节介绍的 ArrayList,而是 Arrays 类的一个内部类,在这个内部类的实现中,内部用的的数组就是传入的数组,没有拷贝,也不会动态改变大小,所以对数组的修改也会反映到 List 中,对 List 调用 add/remove 方法会抛出异常。

要使用 ArrayList 完整的方法,应该新建一个 ArrayList,如下所示

List<Integer> list = new ArrayList<Integer>(Arrays.asList(a));

9.1.5.3 容量大小控制

ArrayList 还提供了两个 public 方法,可以控制内部使用的数组大小,一个是:

public void ensureCapacity(int minCapacity)

它可以确保数组的大小至少为 minCapacity,如果不够,会进行扩展。如果已经预知 ArrayList 需要比较大的容量,调用这个方法可以减少 ArrayList 内部分配和扩展的次数。

另一个方法是:

public void trimToSize()

它会重新分配一个数组,大小刚好为实际内容的长度。调用这个方法可以节省数组占用的空间。

9.1.6 ArrayList 特点分析

后续我们会介绍各种容器类和数据组织方式,之所以有各种不同的方式,是因为不同方式有不同特点,而不同特点有不同适用场合。考虑特点时,性能是其中一个很重要的部分,但性能不是一个简单的高低之分,对于一种数据结构,有的操作性能高,有的操作性能可能就比较低。

作为程序员,就是要理解每种数据结构的特点,根据场合的不同,选择不同的数据结构

对于 ArrayList,它的特点是:内部采用动态数组实现,这决定了:

  • 可以随机访问,按照索引位置进行访问效率很高,用算法描述中的术语,效率是 O(1),简单说就是可以一步到位。
  • 除非数组已排序,否则按照内容查找元素效率比较低,具体是 O(N),N 为数组内容长度,也就是说,性能与数组长度成正比。
  • 添加元素的效率还可以,重新分配和拷贝数组的开销被平摊了,具体来说,添加 N 个元素的效率为 O(N)。
  • 插入和删除元素的效率比较低,因为需要移动元素,具体为 O(N)。

9.1.7 小结

本文详细介绍了 ArrayList,ArrayList 是日常开发中最常用的类之一。我们介绍了 ArrayList 的用法、基本实现原理、迭代器及其实现、Collection/List/RandomAccess 接口、ArrayList 与数组的相互转换,最后我们分析了 ArrayList 的特点。

ArrayList 的插入和删除的性能比较低,下一节,我们来看另一个同样实现了 List 接口的容器类,LinkedList,它的特点可以说与 ArrayList 正好相反。

9.2 剖析 LinkedList

ArrayList 随机访问效率很高,但插入和删除性能比较低,LinkedList 同样实现了 List 接口,它的特点与 ArrayList 几乎正好相反,本节我们就来详细介绍 LinkedList。

除了实现了 List 接口外,LinkedList 还实现了 Deque 和 Queue 接口,可以按照队列、栈和双端队列的方式进行操作,本节会介绍这些用法,同时介绍其实现原理。

我们先来看它的用法。

9.2.1 用法

9.2.1.1 构造方法

LinkedList 的构造方法与 ArrayList 类似,有两个,一个是默认构造方法,另外一个可以接受一个已有的 Collection,如下所示:

public LinkedList()
public LinkedList(Collection<? extends E> c)

比如,可以这么创建:

List<String> list = new LinkedList<>();
List<String> list2 = new LinkedList<>(
        Arrays.asList(new String[]{
    
    "a","b","c"}));

9.2.1.2 List 接口

LinkedList 与 ArrayList 一样,同样实现了 List 接口,而 List 接口扩展了 Collection 接口,Collection 又扩展了 Iterable 接口,所有这些接口的方法都是可以使用的,使用方法与上节介绍的一样,本节就不再赘述了。

9.2.1.3 队列 (Queue)

LinkedList 还实现了队列接口 Queue,所谓队列就类似于日常生活中的各种排队,特点就是先进先出,在尾部添加元素,从头部删除元素,它的接口定义为:

public interface Queue<E> extends Collection<E> {
    
    
    boolean add(E e);
    boolean offer(E e);
    E remove();
    E poll();
    E element();
    E peek();
} 

Queue 扩展了 Collection,它的主要操作有三个:

  • 在尾部添加元素 (add, offer)
  • 查看头部元素 (element, peek),返回头部元素,但不改变队列
  • 删除头部元素 (remove, poll),返回头部元素,并且从队列中删除

每种操作都有两种形式,有什么区别呢?区别在于,对于特殊情况的处理不同。特殊情况是指,队列为空或者队列为满,为空容易理解,为满是指队列有长度大小限制,而且已经占满了。LinkedList 的实现中,队列长度没有限制,但别的 Queue 的实现可能有。

在队列为空时,element 和 remove 会抛出异常 NoSuchElementException,而 peek 和 poll 返回特殊值 null,在队列为满时,add 会抛出异常 IllegalStateException,而 offer 只是返回 false。

把 LinkedList 当做 Queue 使用也很简单,比如,可以这样:

Queue<String> queue = new LinkedList<>();

queue.offer("a");
queue.offer("b");
queue.offer("c");

while(queue.peek()!=null){
    
    
    System.out.println(queue.poll());    
}

输出为:

a
b
c

9.2.1.4 栈

我们在介绍函数调用原理的时候介绍过栈,栈也是一种常用的数据结构,与队列相反,它的特点是先进后出、后进先出,类似于一个储物箱,放的时候是一件件往上放,拿的时候则只能从上面开始拿。

Java 中有一个类 Stack,用于表示栈,但这个类已经过时了,我们不再介绍。Java 中没有单独的栈接口,栈相关方法包括在了表示双端队列的接口 Deque 中,主要有三个方法:

void push(E e);
E pop();
E peek();

解释下:

  • push 表示入栈,在头部添加元素,栈的空间可能是有限的,如果栈满了,push 会抛出异常 IllegalStateException。
  • pop 表示出栈,返回头部元素,并且从栈中删除,如果栈为空,会抛出异常 NoSuchElementException。
  • peek 查看栈头部元素,不修改栈,如果栈为空,返回 null。

把 LinkedList 当做栈使用也很简单,比如,可以这样:

Deque<String> stack = new LinkedList<>();

stack.push("a");
stack.push("b");
stack.push("c");

while(stack.peek()!=null){
    
    
    System.out.println(stack.pop());    
}

输出为:

c
b
a

9.2.1.5 双端队列 (Deque)

栈和队列都是在两端进行操作,栈只操作头部,队列两端都操作,但尾部只添加、头部只查看和删除,有一个更为通用的操作两端的接口 Deque,Deque 扩展了 Queue,包括了栈的操作方法,此外,它还有如下更为明确的操作两端的方法:

void addFirst(E e);
void addLast(E e);
E getFirst();
E getLast();
boolean offerFirst(E e);
boolean offerLast(E e);
E peekFirst();
E peekLast();
E pollFirst();
E pollLast();
E removeFirst();
E removeLast();

xxxFirst 操作头部,xxxLast 操作尾部。与队列类似,每种操作有两种形式,区别也是在队列为空或满时,处理不同。为空时,getXXX/removeXXX 会抛出异常,而 peekXXX/pollXXX 会返回 null。队列满时,addXXX 会抛出异常,offerXXX 只是返回 false。

栈和队列只是双端队列的特殊情况,它们的方法都可以使用双端队列的方法替代,不过,使用不同的名称和方法,概念上更为清晰

Deque 接口还有一个迭代器方法,可以从后往前遍历

Iterator<E> descendingIterator();

比如,看如下代码:

Deque<String> deque = new LinkedList<>(
        Arrays.asList(new String[]{
    
    "a","b","c"}));
Iterator<String> it = deque.descendingIterator();
while(it.hasNext()){
    
    
    System.out.print(it.next()+" ");
}

输出为:

c b a 

9.2.1.6 用法小结

LinkedList 的用法是比较简单的,与 ArrayList 用法类似,支持 List 接口,只是,LinkedList 增加了一个接口 Deque,可以把它看做队列、栈、双端队列,方便的在两端进行操作。

如果只是用作 List,那应该用 ArrayList 还是 LinkedList 呢?我们需要了解下 LinkedList 的实现原理。

9.2.2 实现原理

9.2.2.1 内部组成

我们知道,ArrayList 内部是数组,元素在内存是连续存放的,但 LinkedList 不是。LinkedList 直译就是链表,确切的说,它的内部实现是双向链表,每个元素在内存都是单独存放的,元素之间通过链接连在一起,类似于小朋友之间手拉手一样。

为了表示链接关系,需要一个节点的概念,节点包括实际的元素,但同时有两个链接,分别指向前一个节点(前驱)和后一个节点(后继),节点是一个内部类,具体定义为:

private static class Node<E> {
    
    
    E item;
    Node<E> next;
    Node<E> prev;

    Node(Node<E> prev, E element, Node<E> next) {
    
    
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

Node 类表示节点,item 指向实际的元素,next 指向下一个节点,prev 指向前一个节点。

LinkedList 内部组成就是如下三个实例变量:

transient int size = 0;
transient Node<E> first;
transient Node<E> last;

我们暂时忽略 transient 关键字,size 表示链表长度,默认为 0,first 指向头节点,last 指向尾节点,初始值都为 null

LinkedList 的所有 public 方法内部操作的都是这三个实例变量,具体是怎么操作的?链接关系是如何维护的?我们看一些主要的方法,先来看 add 方法。

9.2.2.2 add 方法

add 方法的代码为:

public boolean add(E e) {
    
    
    linkLast(e);
    return true;
}

主要就是调用了 linkLast,它的代码为:

void linkLast(E e) {
    
    
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}

代码的基本步骤是:

  1. 创建一个新的节点 newNode。prev 指向原来的尾节点,如果原来链表为空,则为 null。代码为:

    Node<E> newNode = new Node<>(l, e, null);
    
  2. 修改尾节点 last,指向新的最后节点 newNode。代码为:

    last = newNode;
    
  3. 修改前节点的后向链接,如果原来链表为空,则让头节点指向新节点,否则让前一个节点的 next 指向新节点。代码为:

    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    
  4. 增加链表大小。代码为:

    size++
    

modCount++ 的目的与 ArrayList 是一样的,记录修改次数,便于迭代中间检测结构性变化。

我们通过一些图示来更清楚的看一下,比如说,代码为:

List<String> list = new LinkedList<String>();
list.add("a");
list.add("b");

执行完第一行后,内部结构如图 9-1 所示:

在这里插入图片描述

添加完"a"后,内部结构如图 9-2 所示:

在这里插入图片描述

添加完"b"后,内部结构如图 9-3 所示:

在这里插入图片描述

可以看出,与 ArrayList 不同,LinkedList 的内存是按需分配的,不需要预先分配多余的内存,添加元素只需分配新元素的空间,然后调节几个链接即可

9.2.2.3 根据索引访问元素 get

添加了元素,如果根据索引访问元素呢?我们看下 get 方法的代码:

public E get(int index) {
    
    
    checkElementIndex(index);
    return node(index).item;
}

checkElementIndex 检查索引位置的有效性,如果无效,抛出异常,代码为:

private void checkElementIndex(int index) {
    
    
    if (!isElementIndex(index))
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

private boolean isElementIndex(int index) {
    
    
    return index >= 0 && index < size;
}

如果 index 有效,则调用 node 方法查找对应的节点,其 item 属性就指向实际元素内容,node 方法的代码为:

Node<E> node(int index) {
    
    
    if (index < (size >> 1)) {
    
    
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
    
    
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

size>>1 等于 size/2,如果索引位置在前半部分 (index<(size>>1)),则从头节点开始查找,否则,从尾节点开始查找。

可以看出,与 ArrayList 明显不同,ArrayList 中数组元素连续存放,可以直接随机访问,而在 LinkedList 中,则必须从头或尾,顺着链接查找,效率比较低

9.2.2.4 根据内容查找元素

我们看下 indexOf 的代码:

public int indexOf(Object o) {
    
    
    int index = 0;
    if (o == null) {
    
    
        for (Node<E> x = first; x != null; x = x.next) {
    
    
            if (x.item == null)
                return index;
            index++;
        }
    } else {
    
    
        for (Node<E> x = first; x != null; x = x.next) {
    
    
            if (o.equals(x.item))
                return index;
            index++;
        }
    }
    return -1;
}

代码也很简单,从头节点顺着链接往后找,如果要找的是 null,则找第一个 item 为 null 的节点,否则使用 equals 方法进行比较

9.2.2.5 插入元素

add 是在尾部添加元素,如果在头部或中间插入元素呢?可以使用如下方法:

public void add(int index, E element)

它的代码是:

public void add(int index, E element) {
    
    
    checkPositionIndex(index);

    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}

如果 index 为 size,添加到最后面,一般情况,是插入到 index 对应节点的前面,调用方法为 linkBefore,它的代码为:

void linkBefore(E e, Node<E> succ) {
    
    
    final Node<E> pred = succ.prev;
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;
}

参数 succ 表示后继节点。变量 pred 就表示前驱节点。目标就是在 pred 和 succ 中间插入一个节点。插入步骤是:

  1. 新建一个节点 newNode,前驱为 pred,后继为 succ。代码为:

    Node<E> newNode = new Node<>(pred, e, succ);
    
  2. 让后继的前驱指向新节点。代码为:

    succ.prev = newNode;
    
  3. 让前驱的后继指向新节点,如果前驱为空,修改头节点指向新节点。代码为:

    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    
  4. 增加长度。

我们通过图示来更清楚的看下,还是上面的例子,比如,添加一个元素:

list.add(1, "c");

内存结构如图 9-4 所示。

在这里插入图片描述

可以看出,在中间插入元素,LinkedList 只需按需分配内存,修改前驱和后继节点的链接,而 ArrayList 则可能需要分配很多额外空间,且移动所有后续元素

9.2.2.6 删除元素

我们再来看删除元素,代码为:

public E remove(int index) {
    
    
    checkElementIndex(index);
    return unlink(node(index));
}

通过 node 方法找到节点后,调用了 unlink 方法,代码为:

E unlink(Node<E> x) {
    
    
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    if (prev == null) {
    
    
        first = next;
    } else {
    
    
        prev.next = next;
        x.prev = null;
    }

    if (next == null) {
    
    
        last = prev;
    } else {
    
    
        next.prev = prev;
        x.next = null;
    }

    x.item = null;
    size--;
    modCount++;
    return element;
}

删除 x 节点,基本思路就是让 x 的前驱和后继直接链接起来,next 是 x 的后继,prev 是 x 的前驱,具体分为两步:

  1. 第一步是让 x 的前驱的后继指向 x 的后继。如果 x 没有前驱,说明删除的是头节点,则修改头节点指向 x 的后继。
  2. 第二步是让 x 的后继的前驱指向 x 的前驱。如果 x 没有后继,说明删除的是尾节点,则修改尾节点指向 x 的前驱。

我们再通过图示看下,还是上面的例子,如果删除一个元素:

list.remove(1);

内存结构如图 9-5 所示。

在这里插入图片描述

9.2.2.7 原理小结

以上,我们介绍了 LinkedList 的内部组成,以及几个主要方法的实现代码,其他方法的原理也都类似,我们就不赘述了。

前面我们提到,对于队列、栈和双端队列接口,长度可能有限制,LinkedList 实现了这些接口,不过 LinkedList 对长度并没有限制

9.2.3 LinkedList 特点分析

LinkedList 内部是用双向链表实现的,维护了长度、头节点和尾节点,这决定了它有如下特点:

  • 按需分配空间,不需要预先分配很多空间
  • 不可以随机访问,按照索引位置访问效率比较低,必须从头或尾顺着链接找,效率为 O(N/2)。
  • 不管列表是否已排序,只要是按照内容查找元素,效率都比较低,必须逐个比较,效率为 O(N)。
  • 在两端添加、删除元素的效率很高,为 O(1)。
  • 在中间插入、删除元素,要先定位,效率比较低,为 O(N),但修改本身的效率很高,效率为 O(1)。

理解了 LinkedList 和 ArrayList 的特点,我们就能比较容易的进行选择了,如果列表长度未知,添加、删除操作比较多,尤其经常从两端进行操作,而按照索引位置访问相对比较少,则 LinkedList 就是比较理想的选择

猜你喜欢

转载自blog.csdn.net/bm1998/article/details/108042705