数据结构线性表之顺序表ArrayList

一、什么是顺序表

  在了解顺序表之前,需要先了解什么是顺序表?顺序表是线性表的一种,线性表分为顺序表和链表。其中顺序表在java中又分为数组(最简单的顺序表)和ArrayList。为什么我们称其为顺序表呢?原因顾名思义是该数据结构在逻辑上和物理结构上的存储顺序均是连续的。下面,我们就以一张图来说明什么是顺序表。

 

这个图代表逻辑上的9个元素,每一个位置均存储一个数据,数据存储在内存中时的物理地址也是连续的。

下面我们就介绍一下ArrayList的基本操作,增删改查。

二、ArrayList的数据结构

   在此篇文章中,我们只讲ArrayList,数组不再讲解。ArrayList为什么说是顺序表呢?其原因我们可以看到ArrayList内部维护的其实还是一个缓存数组,所有的操作都是基于该数组进行实现。看源码:

    /**
     * The array buffer into which the elements of the ArrayList are stored.
     * The capacity of the ArrayList is the length of this array buffer. Any
     * empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
     * will be expanded to DEFAULT_CAPACITY when the first element is added.
     */
    transient Object[] elementData; // non-private to simplify nested class access

三、ArrayList的扩容

  ArrayList有一套自己的扩容机制,在ArrayList初始化完成之后,如果不指定容量,则缓存容量默认为10.

1     /**
2      * Default initial capacity.
3      */
4     private static final int DEFAULT_CAPACITY = 10;

  在每次add操作时,会进行扩容计算,整个流程源码如下:

 1     ensureCapacityInternal(size + 1);  // Increments modCount!!    添加元素时调用扩容
 2 
 3     private void ensureCapacityInternal(int minCapacity) {
 4         ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
 5     }
 6 
 7     private static int calculateCapacity(Object[] elementData, int minCapacity) { // 得到最小的扩容量
 8         if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
 9             return Math.max(DEFAULT_CAPACITY, minCapacity); // 获取默认的容量和传入参数较大的一个值
10         }
11         return minCapacity;
12     }
13 
14     private void ensureExplicitCapacity(int minCapacity) {
15         modCount++;
16 
17         // overflow-conscious code
18         if (minCapacity - elementData.length > 0)
19             grow(minCapacity); // 扩容开始
20     }
21 
22     /**
23      * Increases the capacity to ensure that it can hold at least the
24      * number of elements specified by the minimum capacity argument.
25      *
26      * @param minCapacity the desired minimum capacity
27      */
28     private void grow(int minCapacity) {
29         // overflow-conscious code
30         int oldCapacity = elementData.length;
31         int newCapacity = oldCapacity + (oldCapacity >> 1); //右移操作,相当于oldCapacity/2  扩容之后为原来的1.5倍,也称之为扩容系数
32         if (newCapacity - minCapacity < 0) // 新容量时否小于最小需要的容量,小于则新容量为最小需要容量
33             newCapacity = minCapacity;
34         if (newCapacity - MAX_ARRAY_SIZE > 0) //新容量比最大数组容量还大,则需要判断
35             newCapacity = hugeCapacity(minCapacity);
36         // minCapacity is usually close to size, so this is a win:
37         elementData = Arrays.copyOf(elementData, newCapacity);
38     }
39 
40     private static int hugeCapacity(int minCapacity) {
41         if (minCapacity < 0) // overflow
42             throw new OutOfMemoryError();
43         return (minCapacity > MAX_ARRAY_SIZE) ?
44             Integer.MAX_VALUE :
45             MAX_ARRAY_SIZE;
46     }

我们分析一下该扩容机制:

  (1)当我们add第一个元素时,此时数组初始化(无参构成时)的还是

      DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}

    则此时 

      minCapacity = DEFAULT_CAPACITY //10

    因此
    ensureExplicitCapacity(int minCapacity)方法中 10 - 0 > 0,所以会进行第一次扩
    容,进入到grow(int minCapacity)方法之后,先获取到oldCapacity为0,newCapacity
    通过位移运算之后也为0,则newCapacity=minCapacity=10;容量最终确定为10,最后进
    行扩容操作copyOf。
  (2)当第二次add元素操作时,minCapacity 为2,此时elementData.length()在添加第一个
    元素后扩容成 10 了(length=10)。此时,
    minCapacity - elementData.length > 0 (2 - 10 > 0)不成立,所以不会进入
    grow(minCapacity) 方法。自然就不会扩容,直至到添加第11个元素时,
    minCapacity(11) > elementData.length(10)成立,进入grow方法进行扩容。
    进入grow方法之后,newCapacity变为10 + 10 >> 1 = 15,此时15大于最小需要
    容量,则将数组扩容为15(length = 15)。以此类推下去进行扩容.....
注意:这里的扩容length是指申请物理空间的大小,也就是提前占用位置,方便后面插入元素。
   比如:int[] a = new int[5];  就类似这里的初始化一样先申请5个长度的空间。

四、ArrayList的add()方法

  众所周知,ArrayList适合查询操作,问为什么?肯定都会说效果高(因为它支持下标随机访问),那添加操作呢?效率不高吗?那我们带着这些疑问来一起看看源码。

 1     /**
 2      * Appends the specified element to the end of this list.
 3      *
 4      * @param e element to be appended to this list
 5      * @return <tt>true</tt> (as specified by {@link Collection#add})
 6      */
 7     public boolean add(E e) {
 8         ensureCapacityInternal(size + 1);  // Increments modCount!!
 9         elementData[size++] = e;
10         return true;
11     }
 1     /**
 2      * Inserts the specified element at the specified position in this
 3      * list. Shifts the element currently at that position (if any) and
 4      * any subsequent elements to the right (adds one to their indices).
 5      *
 6      * @param index index at which the specified element is to be inserted
 7      * @param element element to be inserted
 8      * @throws IndexOutOfBoundsException {@inheritDoc}
 9      */
10     public void add(int index, E element) {
11         rangeCheckForAdd(index);
12 
13         ensureCapacityInternal(size + 1);  // Increments modCount!!
14         System.arraycopy(elementData, index, elementData, index + 1,
15                          size - index);
16         elementData[index] = element;
17         size++;
18     }

这里我们只看这两种添加,另外的两种addAll思想和这里的第二种方法类似,就不再做过多的介绍。不难看出二者的区别:

(1)、add(E e) 做的操作第一步先进行缓存容量的计算,第二步直接给当前维护的内部缓存数组下一个数据位置填充当前添加的数据。从逻辑结构来看,它是直接在数组的末尾添加了元素,从物理结构来考虑,是直接在当前数据存储的物理地址最后面进行开辟了一块空间进行连续存储,同时给当前维护数组大小的变量size++操作。

(2)、add(int index, E element) 操作就有趣的多,它是在当前下标为index的位置添加element,然后将原来index位置的元素进行后移(并不会将原来index位置元素覆盖掉),先不看源码,我们可以想到数组后移,必定会需要先扩容,然后再进行移动数据。那么问题来了,如果现在有5000个元素,我要在index = 2 的位置插入一个元素,那要移动4998位,可想而知,在磁盘上进行移动操作,和直接在末尾追加操作哪个效率高就不用我多说了。现在我们看看源码时如何实现的,首先去检查插入的index位是不是越界,然后再计算缓存容量,达到缓存界限及时扩容(物理地址上的扩容)。然后做一个arraycopy的动作,这个动作用于将当前维护的数组size变为size+1的大小,同时进行移动数据操作。最后进行index位置赋值位element。

综上,ArrayList的add操作一定效率低吗?答案当然是不一定,如果是在ArrayList的末尾添加元素,效率当然高。另外的两种addAll同样源码会对其先toArray操作,再arraycopy操作。

五、ArrayList的remove()方法

  在这里,肯定有很多同学疑惑,在工作中写代码时发现写了一个for循环去删除某个条件的值时,发现有时写的会偶尔报错,有时成功,有时失败报异常。为何会出现这种情况呢?那我们接下来就看看它的源码是如何操作,看完之后或许你会恍然大悟。ArrayList作为容器,肯定会为我们提供容器的基本操作方法。remove就是为我们提供的其中一个。

(1)根据下标删除指定元素:

 1     /**
 2      * Removes the element at the specified position in this list.
 3      * Shifts any subsequent elements to the left (subtracts one from their
 4      * indices).
 5      *
 6      * @param index the index of the element to be removed
 7      * @return the element that was removed from the list
 8      * @throws IndexOutOfBoundsException {@inheritDoc}
 9      */
10     public E remove(int index) {
11         rangeCheck(index);
12 
13         modCount++;
14         E oldValue = elementData(index);
15 
16         int numMoved = size - index - 1;
17         if (numMoved > 0)
18             System.arraycopy(elementData, index+1, elementData, index,
19                              numMoved);
20         elementData[--size] = null; // clear to let GC do its work
21 
22         return oldValue;
23     }

  我们可以看出,源码所做的事情是先获取到index位置的元素,然后进行arraycopy操作,进行将数组元素前移(底层使用for循环),最关键的一点是最后一句 

elementData[--size] = null;
 这一行做了一个操作,将数组的size进行减1操作,这才是为什么有的同学在使用for循环删除时
会发生异常的根本原因。

 例如:ArrayList中的值为[1,3,3,2,5,2],现在要删除为3的元素。现在我们分析两种for循环写法,
    并来讨论问题。
A)第一种:
 1         List<String> list = new ArrayList<>();
 2         list.add("1");
 3         list.add("3");
 4         list.add("3");
 5         list.add("2");
 6         list.add("5");
 7         list.add("2");
 8         for(int i=0;i<list.size();i++)
 9             if("3".equals(list.get(i)))
10                 list.remove(i);
  通过这段代码,先考虑两个问题:
  第一个问题:程序运行会报错吗?
  第二个问题:如果程序不报错那么删除后结果又是多少?
  【1】
当然,上面程序运行是不会报错的,那么有同学又会疑惑上面不是刚说了删除之后会
     size--操作吗,确实是会减减操作,但是这里看清楚第8行for循环里面的界限是
     list.size();意思就是说我每次循环
时都会去获取一下当前的size()大小,
     当i=1时,此时size=6,list.get(1)时满足"3",则会调用remove删掉下标为1
     的元素,删掉之后,size就变成了5,此时原本在list当中第二次出现的3下标为2
     在经过remove之后的list当中变成了1,操作完remove之后i变成了2,而此时的
     list中下标为2的位置元素却变成了2。依次这样遍历下去,最终导致删除后的结果
     为[1,3,2,5,2]。所以,即使不报错,程序运行完之后也和我们预期结果不一致。
B)第二种:
 1         List<String> list = new ArrayList<>();
 2         list.add("1");
 3         list.add("3");
 4         list.add("3");
 5         list.add("2");
 6         list.add("5");
 7         list.add("2");
 8         int size = list.size();
 9         for(int i=0;i<size;i++)
10             if("3".equals(list.get(i)))
11                 list.remove(i);

    乍一眼看上去和上面的例子没有区别,但是请仔细看第8行,将list大小提前获取到,for循环时每次去遍历的界限就是该大小。这种写法如果有满足条件的元素一旦被删掉,程序必然会抛出下标越界的异常。原因还是list的大小已经被改变。

(2)同样,删除指定元素objec也是同理:

 1     /**
 2      * Removes the first occurrence of the specified element from this list,
 3      * if it is present.  If the list does not contain the element, it is
 4      * unchanged.  More formally, removes the element with the lowest index
 5      * <tt>i</tt> such that
 6      * <tt>(o==null&nbsp;?&nbsp;get(i)==null&nbsp;:&nbsp;o.equals(get(i)))</tt>
 7      * (if such an element exists).  Returns <tt>true</tt> if this list
 8      * contained the specified element (or equivalently, if this list
 9      * changed as a result of the call).
10      *
11      * @param o element to be removed from this list, if present
12      * @return <tt>true</tt> if this list contained the specified element
13      */
14     public boolean remove(Object o) {
15         if (o == null) {
16             for (int index = 0; index < size; index++)
17                 if (elementData[index] == null) {
18                     fastRemove(index);
19                     return true;
20                 }
21         } else {
22             for (int index = 0; index < size; index++)
23                 if (o.equals(elementData[index])) {
24                     fastRemove(index);
25                     return true;
26                 }
27         }
28         return false;
29     }

  指定删除元素比直接删除指定下标更为复杂一点,内部需要先去for循环遍历找到该元素的index,再根据index去删除,fastRemove(index)方法也是通过arraycopy形式进行移动元素,之后进行size减1操作。源码:

 1     /*
 2      * Private remove method that skips bounds checking and does not
 3      * return the value removed.
 4      */
 5     private void fastRemove(int index) {
 6         modCount++;
 7         int numMoved = size - index - 1;
 8         if (numMoved > 0)
 9             System.arraycopy(elementData, index+1, elementData, index,
10                              numMoved);
11         elementData[--size] = null; // clear to let GC do its work
12     }

  因此,ArrayList中的remove方法可能会带来一些隐患,使用时需要注意,同时效率也是不言而喻的。如果非要使用remove并且还不会发生异常,那我们又该怎么办呢?请看下面第五节。

六、ArrayList的Iterator

  ArrayList的内部维护了内部类Iterator,每一个容器都会有自己的迭代器,迭代器作用是为了可以快速的去轮询ArrayList容器,API建议我们去使用迭代器轮询元素,而不是使用for循环,迭代器为我们提供了规范的接口。

 1     /**
 2      * Returns {@code true} if the iteration has more elements.
 3      * (In other words, returns {@code true} if {@link #next} would
 4      * return an element rather than throwing an exception.)
 5      *
 6      * @return {@code true} if the iteration has more elements
 7      */
 8     boolean hasNext();
 9 
10     /**
11      * Returns the next element in the iteration.
12      *
13      * @return the next element in the iteration
14      * @throws NoSuchElementException if the iteration has no more elements
15      */
16     E next();
17 
18     /**
19      * Removes from the underlying collection the last element returned
20      * by this iterator (optional operation).  This method can be called
21      * only once per call to {@link #next}.  The behavior of an iterator
22      * is unspecified if the underlying collection is modified while the
23      * iteration is in progress in any way other than by calling this
24      * method.
25      *
26      * @implSpec
27      * The default implementation throws an instance of
28      * {@link UnsupportedOperationException} and performs no other action.
29      *
30      * @throws UnsupportedOperationException if the {@code remove}
31      *         operation is not supported by this iterator
32      *
33      * @throws IllegalStateException if the {@code next} method has not
34      *         yet been called, or the {@code remove} method has already
35      *         been called after the last call to the {@code next}
36      *         method
37      */
38     default void remove() {
39         throw new UnsupportedOperationException("remove");
40     }

  ArrayList的内部类去实现了该接口,先看源码:

 1  /**
 2      * An optimized version of AbstractList.Itr
 3      */
 4     private class Itr implements Iterator<E> {
 5         int cursor;       // index of next element to return
 6         int lastRet = -1; // index of last element returned; -1 if no such
 7         int expectedModCount = modCount;
 8 
 9         Itr() {}
10 
11         public boolean hasNext() {
12             return cursor != size;
13         }
14 
15         @SuppressWarnings("unchecked")
16         public E next() {
17             checkForComodification();
18             int i = cursor;
19             if (i >= size)
20                 throw new NoSuchElementException();
21             Object[] elementData = ArrayList.this.elementData;
22             if (i >= elementData.length)
23                 throw new ConcurrentModificationException();
24             cursor = i + 1;
25             return (E) elementData[lastRet = i];
26         }
27 
28         public void remove() {
29             if (lastRet < 0)
30                 throw new IllegalStateException();
31             checkForComodification();
32 
33             try {
34                 ArrayList.this.remove(lastRet);
35                 cursor = lastRet;
36                 lastRet = -1;
37                 expectedModCount = modCount;
38             } catch (IndexOutOfBoundsException ex) {
39                 throw new ConcurrentModificationException();
40             }
41         }
42 }

  (1)hasNext()方法,size为ArrayList内容维护数组实际大小的值,cursor游标为当前访问的位置,如果游标位置刚好指到数组最后一个位置时则为false,此时停止轮询遍历。

  (2)next()方法,游标从0开始,每次+1,同时记录最后一次访问的下标,直接从数组中获取该游标减1(这里的i)的值。

  (3)remove方法,先调用ArrayList容器提供的remove方法,上面已经看过remove方法,此时size会减1,同时此时迭代器的下标已经到了该删除元素的下一个位置,所以为了防止下标越界,所以将游标位置前移一位,cursor的下一次访问从删除元素位置开始。这样就保证了删除时不会发生异常。

七、ArrayList的set()方法

  该方法给某个下标为修改为设置的值,不用过多解释,直接看源码。

 1     /**
 2      * Replaces the element at the specified position in this list with
 3      * the specified element.
 4      *
 5      * @param index index of the element to replace
 6      * @param element element to be stored at the specified position
 7      * @return the element previously at the specified position
 8      * @throws IndexOutOfBoundsException {@inheritDoc}
 9      */
10     public E set(int index, E element) {
11         rangeCheck(index);
12 
13         E oldValue = elementData(index);
14         elementData[index] = element;
15         return oldValue;
16     }

八、ArrayList的get()方法

  这里也不做过多解释,直接看源码就明白。

 1     /**
 2      * Returns the element at the specified position in this list.
 3      *
 4      * @param  index index of the element to return
 5      * @return the element at the specified position in this list
 6      * @throws IndexOutOfBoundsException {@inheritDoc}
 7      */
 8     public E get(int index) {
 9         rangeCheck(index);
10 
11         return elementData(index);
12     }

九、总结

  ArrayList作为容器,加上前面已经分析过它的源码,则可以确定出:

  (1)在ArrayList的尾部插入效率高,随机访问效率高(内部是一个数组)。

  (2)但是如果要在中间插入或者删除则效率低(需要对数组里面的节点进行位移操作arraycopy非常耗时)。

  (3)使用for循环时不要轻易使用remove方法(即使不报错,可能运行结果和预期结果也会不一致)。

  (4)非要使用remove方法,建议使用迭代器的remove方法。

  (4)遍历时推荐使用迭代器,不推荐使用for循环。

 

posted @ 2020-11-29 19:24  bug_easy  阅读(292)  评论(0编辑  收藏  举报