毫不留情地揭開 ArrayList 和 LinkedList 之間的神祕面紗
先看再點贊,給自己一點思考的時間,思考過後請毫不猶豫微信搜尋【沉默王二】,關注這個靠才華苟且的程式設計師。
本文 GitHub github.com/itwanger 已收錄,裡面還有技術大佬整理的面試題,以及二哥的系列文章。
ArrayList 和 LinkedList 是 List 介面的兩種不同實現,並且兩者都不是執行緒安全的。但初學者往往搞不清楚它們兩者之間的區別,不知道什麼時候該用 ArrayList,什麼時候該用 LinkedList,那這篇文章就來傳道受業解惑一下。
ArrayList 內部使用的動態陣列來儲存元素,LinkedList 內部使用的雙向連結串列來儲存元素,這也是 ArrayList 和 LinkedList 最本質的區別。
注:本文使用的 JDK 原始碼版本為 14,小夥伴如果發現文章中的原始碼和自己本地的不同時,不要擔心,不是我原始碼貼錯了,也不是你本地的原始碼錯了,只是版本不同而已。
由於 ArrayList 和 LinkedList 內部使用的儲存方式不同,導致它們的各種方法具有不同的時間複雜度。先來通過維基百科理解一下時間複雜度這個概念。
在電腦科學中,演算法的時間複雜度(Time complexity)是一個函式,它定性描述該演算法的執行時間。這是一個代表演算法輸入值的字串的長度的函式。時間複雜度常用大 O 符號表述,不包括這個函式的低階項和首項係數。使用這種方式時,時間複雜度可被稱為是漸近的,亦即考察輸入值大小趨近無窮時的情況。例如,如果一個演算法對於任何大小為 n (必須比 n0 大)的輸入,它至多需要 5n3+3n 的時間執行完畢,那麼它的漸近時間複雜度是 O(n3)。
對於 ArrayList 來說:
1)get(int index)
方法的時間複雜度為 O(1),因為是直接從底層陣列根據下標獲取的,和陣列長度無關。
public E get(int index) {
Objects.checkIndex(index, size);
return elementData(index);
}
這也是 ArrayList 的最大優點。
2)add(E e)
方法會預設將元素新增到陣列末尾,但需要考慮到陣列擴容的情況,如果不需要擴容,時間複雜度為 O(1)。
public boolean add(E e) {
modCount++;
add(e, elementData, size);
return true;
}
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow();
elementData[s] = e;
size = s + 1;
}
如果需要擴容的話,並且不是第一次(oldCapacity > 0
)擴容的時候,內部執行的 Arrays.copyOf()
方法是耗時的關鍵,需要把原有陣列中的元素複製到擴容後的新陣列當中。
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, /* minimum growth */
oldCapacity >> 1 /* preferred growth */);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
3)add(int index, E element)
方法將新的元素插入到指定的位置,考慮到需要複製底層陣列(根據之前的判斷,擴容的話,陣列可能要複製一次),根據最壞的打算(不管需要不需要擴容,System.arraycopy()
肯定要執行),所以時間複雜度為 O(n)。
public void add(int index, E element) {
rangeCheckForAdd(index);
modCount++;
final int s;
Object[] elementData;
if ((s = size) == (elementData = this.elementData).length)
elementData = grow();
System.arraycopy(elementData, index,
elementData, index + 1,
s - index);
elementData[index] = element;
size = s + 1;
}
來執行以下程式碼,把沉默王八插入到下標為 2 的位置上。
ArrayList<String> list = new ArrayList<>();
list.add("沉默王二");
list.add("沉默王三");
list.add("沉默王四");
list.add("沉默王五");
list.add("沉默王六");
list.add("沉默王七");
list.add(2, "沉默王八");
System.arraycopy()
執行完成後,下標為 2 的元素為沉默王四,這一點需要注意。也就是說,在陣列中插入元素的時候,會把插入位置以後的元素依次往後複製,所以下標為 2 和下標為 3 的元素都為沉默王四。
之後再通過 elementData[index] = element
將下標為 2 的元素賦值為沉默王八;隨後執行 size = s + 1
,陣列的長度變為 7。
4)remove(int index)
方法將指定位置上的元素刪除,考慮到需要複製底層陣列,所以時間複雜度為 O(n)。
public E remove(int index) {
Objects.checkIndex(index, size);
final Object[] es = elementData;
@SuppressWarnings("unchecked") E oldValue = (E) es[index];
fastRemove(es, index);
return oldValue;
}
private void fastRemove(Object[] es, int i) {
modCount++;
final int newSize;
if ((newSize = size - 1) > i)
System.arraycopy(es, i + 1, es, i, newSize - i);
es[size = newSize] = null;
}
對於 LinkedList 來說:
1)get(int index)
方法的時間複雜度為 O(n),因為需要迴圈遍歷整個連結串列。
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
LinkedList.Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
LinkedList.Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
LinkedList.Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
下標小於連結串列長度的一半時,從前往後遍歷;否則從後往前遍歷,這樣從理論上說,就節省了一半的時間。
如果下標為 0 或者 list.size() - 1
的話,時間複雜度為 O(1)。這種情況下,可以使用 getFirst()
和 getLast()
方法。
public E getFirst() {
final LinkedList.Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return f.item;
}
public E getLast() {
final LinkedList.Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return l.item;
}
first 和 last 在連結串列中是直接儲存的,所以時間複雜度為 O(1)。
2)add(E e)
方法預設將元素新增到連結串列末尾,所以時間複雜度為 O(1)。
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final LinkedList.Node<E> l = last;
final LinkedList.Node<E> newNode = new LinkedList.Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
3)add(int index, E element)
方法將新的元素插入到指定的位置,需要先通過遍歷查詢這個元素,然後再進行插入,所以時間複雜度為 O(n)。
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
如果下標為 0 或者 list.size() - 1
的話,時間複雜度為 O(1)。這種情況下,可以使用 addFirst()
和 addLast()
方法。
public void addFirst(E e) {
linkFirst(e);
}
private void linkFirst(E e) {
final LinkedList.Node<E> f = first;
final LinkedList.Node<E> newNode = new LinkedList.Node<>(null, e, f);
first = newNode;
if (f == null)
last = newNode;
else
f.prev = newNode;
size++;
modCount++;
}
linkFirst()
只需要對 first 進行更新即可。
public void addLast(E e) {
linkLast(e);
}
void linkLast(E e) {
final LinkedList.Node<E> l = last;
final LinkedList.Node<E> newNode = new LinkedList.Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
linkLast()
只需要對 last 進行更新即可。
需要注意的是,有些文章裡面說,LinkedList 插入元素的時間複雜度近似 O(1),其實是有問題的,因為 add(int index, E element)
方法在插入元素的時候會呼叫 node(index)
查詢元素,該方法之前我們之間已經確認過了,時間複雜度為 O(n),即便隨後呼叫 linkBefore()
方法進行插入的時間複雜度為 O(1),總體上的時間複雜度仍然為 O(n) 才對。
void linkBefore(E e, LinkedList.Node<E> succ) {
// assert succ != null;
final LinkedList.Node<E> pred = succ.prev;
final LinkedList.Node<E> newNode = new LinkedList.Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
4)remove(int index)
方法將指定位置上的元素刪除,考慮到需要呼叫 node(index)
方法查詢元素,所以時間複雜度為 O(n)。
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
E unlink(LinkedList.Node<E> x) {
// assert x != null;
final E element = x.item;
final LinkedList.Node<E> next = x.next;
final LinkedList.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;
}
通過時間複雜度的比較,以及原始碼的分析,我相信小夥伴們在選擇的時候就有了主意,對吧?
需要注意的是,如果列表很大很大,ArrayList 和 LinkedList 在記憶體的使用上也有所不同。LinkedList 的每個元素都有更多開銷,因為要儲存上一個和下一個元素的地址。ArrayList 沒有這樣的開銷。
但是,ArrayList 佔用的記憶體在宣告的時候就已經確定了(預設大小為 10),不管實際上是否添加了元素,因為複雜物件的陣列會通過 null 來填充。LinkedList 在宣告的時候不需要指定大小,元素增加或者刪除時大小隨之改變。
另外,ArrayList 只能用作列表;LinkedList 可以用作列表或者佇列,因為它還實現了 Deque 介面。
我在寫這篇文章的時候,遇到了一些問題,所以請教了一些大廠的技術大佬,結果有個朋友說,“如果真的不知道該用 ArrayList 還是 LinkedList,就選擇 ArrayList 吧!”
我當時以為他在和我開玩笑呢,結果通過時間複雜度的分析,好像他說得有道理啊。查詢的時候,ArrayList 比 LinkedList 快,這是毋庸置疑的;插入和刪除的時候,之前有很多資料說 LinkedList 更快,時間複雜度為 O(1),但其實不是的,因為要遍歷列表,對吧?
反而 ArrayList 更輕量級,不需要在每個元素上維護上一個和下一個元素的地址。
我這樣的結論可能和大多數文章得出的結論不符,那麼我想,選擇權交給小夥伴們,你們在使用的過程中認真地思考一下,並且我希望你們把自己的思考在留言區放出來。
我是沉默王二,一枚有顏值卻靠才華苟且的程式設計師。關注即可提升學習效率,別忘了三連啊,點贊、收藏、留言,我不挑,奧利給。
注:如果文章有任何問題,歡迎毫不留情地指正。
如果你覺得文章對你有些幫助歡迎微信搜尋「沉默王二」第一時間閱讀,回覆「小白」更有我肝了 4 萬+字的 Java 小白手冊 2.0 版,本文 GitHub github.com/itwanger 已收錄,歡迎 star。