前言
在日常的java開發中,我們經常用到各種集合類,而List是其中最常見的一種;以前我們在使用數組的時候,無論是c++或者java,都要指定它的大小;而List居然不用指定大小,太神奇了,接下來讓我們仔細分析一下它們的實現
LinkedList:
- 主要成員變量:
transient int size = 0; //List長度
transient Node<E> first;//頭指針
transient Node<E> last;//尾指針
- add方法(將元素放入List尾部):
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++;
}
也就是說,每次添加一個節點到末尾時,都是新申請一個鏈表節點,然后再縫縫補補,把相關的鏈表指針接在一起;復雜度為O(1);
再看看另外一個add方法:
- add(int index, E element)(將元素插入到某個下標位置):
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
<p>
再看node()方法(找到下標對應的鏈表節點):
Node<E> node(int index) {
// assert isElementIndex(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;
}
}
其尋址復雜度是O(n)的,唯一的優化點就在于,看下標離頭指針近還是離尾指針近,復雜度做了常數上的優化,從n下降到n/2, 但還是O(n);
剩下的刪查都涉及到尋址的時間損耗,復雜度也都是O(n),它們的實現都是類似的,就不贅訴了。
小優化:其實如果我們自己實現一個LinkedList,完全可以把指針信息加在節點上,這樣就可以避免一些尋址上的時間損耗了。
ArrayList(與c++里的vector類似)
- 成員變量:
transient Object[] elementData; // 存儲元素的載體是數組
private int size;//List當前長度
- 主構造方法:
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
沒有什么特別的,就是申請一個定長的數組
- add(E e)方法:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
第一行是確保數組載體容量足夠,后邊就是把下標為size+1的元素賦值成e;
它調用了ensureCapacityInternal()方法:
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
又調用了ensureExplicitCapacity()方法:
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
再看grow()方法:
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
里邊關鍵的一句代碼就是:
int newCapacity = oldCapacity + (oldCapacity >> 1);
意思就是,新的數組容量變成原數組的大約1.5倍(看不懂的可以去了解一下位運算),新的數組申請內存之后,再把老的數組里的元素全copy到新數組就完成了;
那么為什么每次容量不足的時候,要擴充到1.5倍呢?通過分析我們可以發現,每次擴充1.5倍容量,那么假設最后容量擴充為n,那么總體上的申請空間的數量近似于:f(n) = n + n * 2/3 + n * (2/3)^2 + n * (2/3)^3 + ... ,根據等比數列公式可以知道,這個表達式的值為: f(n) = 3 * n = O(n)
采用倍增的方法擴容,優點在于總體的復雜度數量級是線性的,但是也不可避免的可能會有空間浪費;在最極端的情況下,會有接近1/3的空間是沒有利用上的;因此,在List會很大,且能預先大致估算出List會有多大的前提下,為了減少系統的內存消耗和頻繁GC,應盡量使用以下構造方法來申請List:
public ArrayList(int initialCapacity);
- add(int index, E element)方法(將元素插入到指定下標):
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
跟add(E element)方法的最大的差別就在于,在賦值之前,要先把該下標以及后面的元素全都向后移一位,其復雜度為O(n);
- 接下來看get(int index)方法:
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
檢查下標合法性,再調用elementData():
E elementData(int index) {
return (E) elementData[index];
}
直接是數組的根據下標隨機訪問的操作,復雜度是O(1),再強轉成E類型就返回了;
- remove(int index)方法:
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; // clear to let GC do its work
return oldValue;
}
在刪除指定下標的元素時,如果這個元素不是最后一個,那么要將后面的元素全部向前移一位,復雜度也是O(n), 另外還有一點就是,刪除了這個元素之后,要把最大下標的那個元素賦值成null,方便系統進行GC(系統GC時,會根據對象是否被引用來判斷對象是否可以回收)
- remove(Object o)方法:
public boolean remove(Object o) {
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
for (int index = 0; index < size; index++)
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}
定位到元素位置復雜度為O(n), fastRemove(index)的復雜度也是O(n);
另外,初看這段代碼,感覺寫得有點“啰嗦”,但實際上確實是有必要的:List元素允許為空,所以要特判該元素是否為空,為空時直接用==null來判斷,而不為空時,才能調用equals方法進行比較,否則會有空指針異常;所以這段代碼也是JDK嚴謹性的一種體現;
- 以下表格列出了兩者在各種操作下的復雜度:
| 操作\List實現類 | LinkedList | ArrayList |
| ------------- |:-------------:| -----:|
| add(E e) | O(1) | O(1) |
| add(int index, E element) | O(n) | O(n) |
| get(int index) | O(n) | O(1) |
| remove(int index) | O(n) | O(n) |
| remove(Object o) | O(n) | O(n) |
從上表看,LinkedList幾乎沒有比ArrayList優越的地方;另外,LinkedList相比ArrayList沒有0.5倍的空間浪費,但是其每個節點都有前后指針的內存占用,且每次新增一個元素時都要新申請一個Node,而ArrayList則是一次性批量申請;所以,當List長度比較大的時候,肯定是ArrayList效率比較高。
如果說LinkedList有優點的話,可能就是它不需要申請連續的內存,所以建議大家除了極端情況,大部分時候都使用ArrayList。