第三章 ArrayList
譯者:飛龍
自豪地采用谷歌翻譯
本章一舉兩得:我展示了上一個練習的解法,并展示了一種使用攤銷分析來劃分算法的方法。
3.1 劃分MyArrayList
的方法
對于許多方法,我們不能通過測試代碼來確定增長級別。例如,這里是MyArrayList
的get
的實現:
public E get(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException();
}
return array[index];
}
get
中的每個東西都是常數時間的。所以get
是常數時間,沒問題。
現在我們已經劃分了get
,我們可以使用它來劃分set
。這是我們以前的練習中的set
:
public E set(int index, E element) {
E old = get(index);
array[index] = element;
return old;
}
該解決方案的一個有些機智的部分是,它不會顯式檢查數組的邊界;它利用get
,如果索引無效則引發異常。
set
中的一切,包括get
的調用都是常數時間,所以set
也是常數時間。
接下來我們來看一些線性的方法。例如,以下是我的實現indexOf
:
public int indexOf(Object target) {
for (int i = 0; i<size; i++) {
if (equals(target, array[i])) {
return i;
}
}
return -1;
}
每次在循環中,indexOf
調用equals
,所以我們首先要劃分equals
。這里就是:
private boolean equals(Object target, Object element) {
if (target == null) {
return element == null;
} else {
return target.equals(element);
}
}
此方法調用target.equals
;這個方法的運行時間可能取決于target
或element
的大小,但它不依賴于該數組的大小,所以出于分析indexOf
的目的,我們認為這是常數時間。
回到之前的indexOf
,循環中的一切都是常數時間,所以我們必須考慮的下一個問題是:循環執行多少次?
如果我們幸運,我們可能會立即找到目標對象,并在測試一個元素后返回。如果我們不幸,我們可能需要測試所有的元素。平均來說,我們預計測試一半的元素,所以這種方法被認為是線性的(除了在不太可能的情況下,我們知道目標元素在數組的開頭)。
remove
的分析也類似。這里是我的時間。
public E remove(int index) {
E element = get(index);
for (int i=index; i<size-1; i++) {
array[i] = array[i+1];
}
size--;
return element;
}
它使用get
,這是常數時間,然后從index
開始遍歷數組。如果我們刪除列表末尾的元素,循環永遠不會運行,這個方法是常數時間。如果我們刪除第一個元素,我們遍歷所有剩下的元素,它們是線性的。因此,這種方法同樣被認為是線性的(除了在特殊情況下,我們知道元素在末尾,或到末尾距離恒定)。
3.2 add
的劃分
這里是add
的一個版本,接受下標和元素作為參數:
public void add(int index, E element) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException();
}
// add the element to get the resizing
add(element);
// shift the other elements
for (int i=size-1; i>index; i--) {
array[i] = array[i-1];
}
// put the new one in the right place
array[index] = element;
}
這個雙參數的版本,叫做add(int, E)
,它使用了單參數的版本,稱為add(E)
,它將新的元素放在最后。然后它將其他元素向右移動,并將新元素放在正確的位置。
在我們可以劃分雙參數add
之前,我們必須劃分單參數add
:
public boolean add(E element) {
if (size >= array.length) {
// make a bigger array and copy over the elements
E[] bigger = (E[]) new Object[array.length * 2];
System.arraycopy(array, 0, bigger, 0, array.length);
array = bigger;
}
array[size] = element;
size++;
return true;
}
單參數版本很難分析。如果數組中存在未使用的空間,那么它是常數時間,但如果我們必須調整數組的大小,它是線性的,因為System.arraycopy
所需的時間與數組的大小成正比。
那么add
是常數還是線性時間的?我們可以通過考慮一系列n
個添加中,每次添加的平均操作次數,來分類此方法。為了簡單起見,假設我們以一個有2
個元素的空間的數組開始。
- 我們第一次調用
add
時,它會在數組中找到未使用的空間,所以它存儲1
個元素。 - 第二次,它在數組中找到未使用的空間,所以它存儲
1
個元素。 - 第三次,我們必須調整數組的大小,復制
2
個元素,并存儲1
個元素。現在數組的大小是4
。 - 第四次存儲
1
個元素。 - 第五次調整數組的大小,復制
4
個元素,并存儲1
個元素。現在數組的大小是8
。 - 接下來的
3
個添加儲存3
個元素。 - 下一個添加復制
8
個并存儲1
個。現在的大小是16
。 - 接下來的
7
個添加復制了7
個元素。
以此類推,總結一下:
-
4
次添加之后,我們儲存了4
個元素,并復制了兩個。 -
8
次添加之后,我們儲存了8
個元素,并復制了6
個。 -
16
次添加之后,我們儲存了16
個元素,并復制了14
個。
現在你應該看到了規律:要執行n
次添加,我們必須存儲n
個元素并復制n-2
個。所以操作總數為n + n - 2
,為2 * n - 2
。
為了得到每個添加的平均操作次數,我們將總和除以n
;結果是2 - 2 / n
。隨著n
變大,第二項2 / n
變小。參考我們只關心n
的最大指數的原則,我們可以認為add
是常數時間的。
有時線性的算法平均可能是常數時間,這似乎是奇怪的。關鍵是我們每次調整大小時都加倍了數組的長度。這限制了每個元素被復制的次數。否則 - 如果我們向數組的長度添加一個固定的數量,而不是乘以一個固定的數量 - 分析就不起作用。
這種劃分算法的方式,通過計算一系列調用中的平均時間,稱為攤銷分析。你可以在 http://thinkdast.com/amort 上閱讀更多信息。重要的想法是,復制數組的額外成本是通過一系列調用展開或“攤銷”的。
現在,如果add(E)
是常數時間,那么add(int, E)
呢?調用add(E)
后,它遍歷數組的一部分并移動元素。這個循環是線性的,除了在列表末尾添加的特殊情況中。因此, add(int, E)
是線性的。
3.3 問題規模
最后一個例子中,我們將考慮removeAll
,這里是MyArrayList
中的實現:
public boolean removeAll(Collection<?> collection) {
boolean flag = true;
for (Object obj: collection) {
flag &= remove(obj);
}
return flag;
}
每次循環中,removeAll
都調用remove
,這是線性的。所以認為removeAll
是二次的很誘人。但事實并非如此。
在這種方法中,循環對于每個collection
中的元素運行一次。如果collection
包含m
個元素,并且我們從包含n
個元素的列表中刪除,則此方法是O(nm)
的。如果collection
的大小可以認為是常數,removeAll
相對于n
是線性的。但是,如果集合的大小與n
成正比,removeAll
則是平方的。例如,如果collection
總是包含100
個或更少的元素, removeAll
則是線性的。但是,如果collection
通常包含的列表中的 1% 元素,removeAll
則是平方的。
當我們談論問題規模時,我們必須小心我們正在討論哪個大小。這個例子演示了算法分析的陷阱:對循環計數的誘人捷徑。如果有一個循環,算法往往是 線性的。如果有兩個循環(一個嵌套在另一個內),則該算法通常是平方的。不過要小心!你必須考慮每個循環運行多少次。如果所有循環的迭代次數與n
成正比,你可以僅僅對循環進行計數之后離開。但是,如在這個例子中,迭代次數并不總是與n
成正比,所以你必須考慮更多。
3.4 鏈接數據結構
對于下一個練習,我提供了List
接口的部分實現,使用鏈表來存儲元素。如果你不熟悉鏈表,你可以閱讀 http://thinkdast.com/linkedlist ,但本部分會提供簡要介紹。
如果數據結構由對象(通常稱為“節點”)組成,其中包含其他節點的引用,則它是“鏈接”的。在鏈表 中,每個節點包含列表中下一個節點的引用。其他鏈接結構包括樹和圖,其中節點可以包含多個其他節點的引用。
這是一個簡單節點的類定義:
public class ListNode {
public Object data;
public ListNode next;
public ListNode() {
this.data = null;
this.next = null;
}
public ListNode(Object data) {
this.data = data;
this.next = null;
}
public ListNode(Object data, ListNode next) {
this.data = data;
this.next = next;
}
public String toString() {
return "ListNode(" + data.toString() + ")";
}
}
該ListNode
對象具有兩個實例變量:data
是某種類型的Object
的引用,并且next
是列表中下一個節點的引用。在列表中的最后一個節點中,按照慣例,next
是null
。
ListNode
提供了幾個構造函數,可以讓你為data
和next
提供值,或將它們初始化為默認值,null
。
你可以將每個ListNode
看作具有單個元素的列表,但更通常,列表可以包含任意數量的節點。有幾種方法可以制作新的列表。一個簡單的選項是,創建一組ListNode
對象,如下所示:
ListNode node1 = new ListNode(1);
ListNode node2 = new ListNode(2);
ListNode node3 = new ListNode(3);
之后將其鏈接到一起,像這樣:
node1.next = node2;
node2.next = node3;
node3.next = null;
或者,你可以創建一個節點并將其鏈接在一起。例如,如果要在列表開頭添加一個新節點,可以這樣做:
ListNode node0 = new ListNode(0, node1);
圖 3.1 鏈表的對象圖
圖 3.1 是一個對象圖,展示了這些變量及其引用的對象。在對象圖中,變量的名稱出現在框內,箭頭顯示它們所引用的內容。對象及其類型(如ListNode和Integer)出現在框外面。
3.5 練習 3
這本書的倉庫中,你會找到你需要用于這個練習的源代碼:
-
MyLinkedList.java
包含List
接口的部分實現,使用鏈表存儲元素。 -
MyLinkedListTest.java
包含用于MyLinkedList
的 JUnit 測試。
運行ant MyArrayList
來運行MyArrayList.java
,其中包含幾個簡單的測試。
然后可以運行ant MyArrayListTest
來運行 JUnit 測試。其中幾個應該失敗。如果你檢查源代碼,你會發現三條 TODO 注釋,表示你應該填充的方法。
在開始之前,讓我們來看看一些代碼。以下是MyLinkedList
的實例變量和構造函數:
public class MyLinkedList<E> implements List<E> {
private int size; // keeps track of the number of elements
private Node head; // reference to the first node
public MyLinkedList() {
head = null;
size = 0;
}
}
如注釋所示,size
跟蹤MyLinkedList
有多少元素;head
是列表中第一個Node
的引用,或者如果列表為空則為null
。
存儲元素數量不是必需的,并且一般來說,保留冗余信息是有風險的,因為如果沒有正確更新,就有機會產生錯誤。它還需要一點點額外的空間。
但是如果我們顯式存儲size
,我們可以實現常數時間的size
方法;否則,我們必須遍歷列表并對元素進行計數,這需要線性時間。
因為我們顯式存儲size
明確地存儲,每次添加或刪除一個元素時,我們都要更新它,這樣一來,這些方法就會減慢,但是它不會改變它們的增長級別,所以很值得。
構造函數將head
設為null,表示空列表,并將size
設為0
。
這個類使用類型參數E
作為元素的類型。如果你不熟悉類型參數,可能需要閱讀本教程:http://thinkdast.com/types。
類型參數也出現在Node
的定義中,嵌套在MyLinkedList
里面:
private class Node {
public E data;
public Node next;
public Node(E data, Node next) {
this.data = data;
this.next = next;
}
}
除了這個,Node
類似于上面的ListNode
。
最后,這是我的add
的實現:
public boolean add(E element) {
if (head == null) {
head = new Node(element);
} else {
Node node = head;
// loop until the last node
for ( ; node.next != null; node = node.next) {}
node.next = new Node(element);
}
size++;
return true;
}
此示例演示了你需要的兩種解決方案:
對于許多方法,作為特殊情況,我們必須處理列表的第一個元素。在這個例子中,如果我們向列表添加列表第一個元素,我們必須修改head
。否則,我們遍歷列表,找到末尾,并添加新節點。
此方法展示了,如何使用for
循環遍歷列表中的節點。在你的解決方案中,你可能會在此循環中寫出幾個變體。注意,我們必須在循環之前聲明node
,以便我們可以在循環之后訪問它。
現在輪到你了。填充indexOf
的主體。像往常一樣,你應該閱讀文檔,位于 http://thinkdast.com/listindof,所以你知道應該做什么。特別要注意它應該如何處理null
。
與上一個練習一樣,我提供了一個輔助方法equals
,它將數組中的一個元素與目標值進行比較,并檢查它們是否相等,并正確處理null
。這個方法是私有的,因為它在這個類中使用,但它不是List
接口的一部分。
完成后,再次運行測試;testIndexOf
,以及依賴于它的其他測試現在應該通過。
接下來,你應該填充雙參數版本的add,它使用索引并將新值存儲在給定索引處。再次閱讀 http://thinkdast.com/listadd 上的文檔,編寫一個實現,并運行測試進行確認。
最后一個:填寫remove
的主體。文檔在這里:http://thinkdast.com/listrem。當你完成它時,所有的測試都應該通過。
一旦你的實現能夠工作,將它與倉庫solution
目錄中的版本比較。
3.6 垃圾回收的注解
在MyArrayList
以前的練習中,如果需要,數字會增長,但它不會縮小。該數組從不收集垃圾,并且在列表本身被銷毀之前,元素不會收集垃圾。
鏈表實現的一個優點是,當元素被刪除時它會縮小,并且未使用的節點可以立即被垃圾回收。
這是我的實現的clear
方法:
public void clear() {
head = null;
size = 0;
}
當我們將head
設為null
時,我們刪除第一個Node
的引用。如果沒有其他Node
的引用(不應該有),它將被垃圾收集。這個時候,第二個Node
引用被刪除,所以它也被垃圾收集。此過程一直持續到所有節點都被收集。
那么我們應該如何劃分clear
?該方法本身包含兩個常數時間的操作,所以它看起來像是常數時間。但是當你調用它時,你將使垃圾收集器做一些工作,它與元素數成正比。所以也許我們應該將其認為是線性的!
這是一個有時被稱為性能 bug 的例子:一個程序做了正確的事情,在這種意義上它是正確的,但它不屬于我們預期的增長級別。在像 Java 這樣的語言中,它在背后做了大量工作的,例如垃圾收集,這種 bug 可能很難找到。