數據結構是編程的起點,理解數據結構可以從三方面入手:
- 邏輯結構。邏輯結構是指數據元素之間的邏輯關系,可分為線性結構和非線性結構,線性表是典型的線性結構,非線性結構包括集合、樹和圖。
- 存儲結構。存儲結構是指數據在計算機中的物理表示,可分為順序存儲、鏈式存儲、索引存儲和散列存儲。數組是典型的順序存儲結構;鏈表采用鏈式存儲;索引存儲的優點是檢索速度快,但需要增加附加的索引表,會占用較多的存儲空間;散列存儲使得檢索、增加和刪除結點的操作都很快,缺點是解決散列沖突會增加時間和空間開銷。
- 數據運算。施加在數據上的運算包括運算的定義和實現。運算的定義是針對邏輯結構的,指出運算的功能;運算的實現是針對存儲結構的,指出運算的具體操作步驟。
因此,本章將以邏輯結構(線性表、樹、散列、優先隊列和圖)為縱軸,以存儲結構和運算為橫軸,分析常見數據結構的定義和實現。
在Java中談到數據結構時,首先想到的便是下面這張Java集合框架圖:
從圖中可以看出,Java集合類大致可分為List、Set、Queue和Map四種體系,其中:
- List代表有序、重復的集合;
- Set代表無序、不可重復的集合;
- Queue代表一種隊列集合實現;
- Map代表具有映射關系的集合。
在實現上,List、Set和Queue均繼承自Collection,因此,Java集合框架主要由Collection和Map兩個根接口及其子接口、實現類組成。
本文將重點探討線性表的定義和實現,首先梳理Collection接口及其子接口的關系,其次從存儲結構(順序表和鏈表)維度分析線性表的實現,最后通過線性表結構導出棧、隊列的模型與實現原理。主要內容如下:
- Iterator、Collection及List接口
- ArrayList / LinkedList實現原理
- Stack / Queue模型與實現
一、Iterator、Collection及List接口
Collection
接口是List
、Set
和Queue
的根接口,抽象了集合類所能提供的公共方法,包含size()
、isEmpty()
、add(E e)
、remove(Object o)
、contains(Object o)
等,iterator()
返回集合類迭代器。
public interface Collection<E> extends Iterable<E> {
int size();
boolean isEmpty();
Iterator<E> iterator();
boolean add(E e);
boolean addAll(Collection<? extends E> c);
boolean remove(Object o);
boolean removeAll(Collection<?> c);
boolean contains(Object o);
boolean containsAll(Collection<?> c);
void clear();
boolean equals(Object o);
int hashCode();
}
Collection
接口繼承自Iterable
接口,實現Iterable
接口的集合類可以通過迭代器對元素進行安全、高效的遍歷,比如for-each,Iterable
的iterator
方法負責返回Iterator
迭代器。
public interface Iterable<T> {
Iterator<T> iterator();
}
Iterator
迭代器包含集合迭代時兩個最常用的方法:hasNext
和next
。hasNext
用於查詢集合是否存在下一項,next
方法用於獲取下一項。
public interface Iterator<E> {
boolean hasNext();
E next();
}
List
接口繼承自Collection
接口,相比於Collection
接口已有的增刪改查的方法,List
主要增加了index屬性和ListIterator
接口。因此,除Collection
接口方法,List
接口的主要方法如下:
public interface List<E> extends Collection<E> {
public E get(int location);
public int indexOf(Object object);
public int lastIndexOf(Object object);
public ListIterator<E> listIterator();
// ……
}
ListIterator
接口繼承Iterator
接口,因此,在正向遍歷方法hasNext
和next
的基礎上,ListIterator
接口增加了實現逆序遍歷的方法hasPrevious
和previous
,使其具有雙向遍歷的特性。如下所示:
public interface ListIterator<E> extends Iterator<E> {
public boolean hasPrevious();
public E previous();
public int previousIndex();
// ……
}
下面舉個栗子說明采用ListIterator
進行雙向遍歷。
List<String> list = new ArrayList<>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
ListIterator<String> listIterator = list.listIterator();
while(listIterator.hasNext()) {
System.out.print(listIterator.next());
}
while (listIterator.hasPrevious()) {
System.out.print(listIterator.previous());
}
ArrayList
通過內部類Itr
實現了ListIterator
接口,Itr
包含指示迭代器當前位置的域cursor
,next()
方法會把cursor
向后推動,相反,previous()
方法則把cursor
向前推動,所以上述代碼能對該List的元素進行雙向遍歷。
另外,在
List
上使用for-each語法遍歷集合時,編譯器判斷List
實現了Iterable
接口,會調用iterator
的方法來代替for循環。
// 程序版本
private void traversalA() {
for (String s : list) {
Log.d("TraversalA Test:", s);
}
}
// 編譯器版本
private void traversalB() {
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String s = iterator.next();
Log.d("TraversalB:", s);
}
}
二、ArrayList / LinkedList實現原理
Java程序員都知道ArrayList 基於數組、LinkedList基於鏈表實現,因此,這里不再對基本原理進行贅述,下面主要從數據結構、添加/刪除方法和迭代器三個方面分別說明ArrayList和LinkedList實現原理:
對比內容 | ArrayList | LinkedList |
---|---|---|
數據結構 | 數組 | 雙向鏈表 |
添加/刪除方法 | System.arraycopy復制 | 改變前后元素的指向 |
迭代器 | Iterator和ListIterator | ListIterator |
2.1 ArrayList實現原理
ArrayList是可改變大小的、基於索引的數組,使用索引讀取數組中的元素的時間復雜度是O(1),但通過索引插入和刪除元素卻需要重排該索引后所有的元素,因此消耗較大。但相比於LinkedList,其內存占用是連續的,空間利用效率更高。
2.1.1 擴容
擴容是ArrayList能夠改變元素存儲數組大小的保證。在JDK1.8中,ArrayList存放元素的數組的默認容量是10,當集合中元素數量超過它時,就需要擴容。另外,ArrayList最大的存儲長度為Integer.MAX_VALUE - 8
(虛擬機可能會在數組中添加一些頭信息,這樣避免內存溢出)。
private static final int DEFAULT_CAPACITY = 10;
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
擴容方法主要通過三步實現:1)保存舊數組;2)擴展新數組;3)把舊數據拷貝回新數組。
// 擴容方法
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);
}
// Arrays拷貝
public static <T> T[] copyOf(T[] original, int newLength) {
return (T[]) copyOf(original, newLength, original.getClass());
}
// 調用System.arraycopy實現
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
T[] copy = ((Object)newType == (Object)Object[].class) ? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength));
return copy;
}
由於oldCapacity >> 1等於oldCapacity / 2,所以擴容后的數組大小為舊數組大小的1.5倍。另外,Arrays中的靜態方法是通過調用Native方法System.arraycopy來實現的。
2.1.2 add / remove方法
當在ArrayList末尾添加/刪除元素時,由於對其他元素沒有影響,所以時間負責度仍為O(1)。這里忽略這種情況,以通過索引插入/刪除數據為例說明add / remove方法的實現:
public void add(int index, E element) {
if (index > size || index < 0)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1, size - index);
elementData[index] = element;
size++;
}
public E remove(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
modCount++;
E oldValue = (E) 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;
}
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos, int length);
添加元素時,首先確保數組容量足夠存放size+1個元素,然后將index后的size-index個元素依次后移一位,在index處保存新加入的元素element,同時增加元素總量;與添加元素相反,刪除元素時將index后的size-(index+1)個元素依次前移動一位,同時減小元素總量。可見,添加/刪除元素均通過調用 System.arraycopy
方法來實現數據的移動,效率較高。但另一方面,從上述實現可以看出,ArrayList並非線程安全,在並發環境下需要使用線程安全的容器類。
2.1.3 Iterator和ListIterator
如前所述,ArrayList
實現了List
接口,其包含兩種迭代器:Iterator
和ListIterator
,ListIterator
相比於Iterator
能實現前向遍歷。在ArrayList
中,通過內部類Itr
實現了Iterator
接口,內部類ListItr
繼承自Itr
並且實現了ListIterator
,因此,ArrayList
的iterator()
方法放回的是Itr
對象,listIterator()
方法反回ListIterator
對象。
private class Itr implements Iterator<E> {
int cursor;
int lastRet = -1;
int expectedModCount = modCount;
// ……
}
private class ListItr extends Itr implements ListIterator<E> {
ListItr(int index) {
super();
cursor = index;
}
// ……
}
public Iterator<E> iterator() {
return new Itr();
}
public ListIterator<E> listIterator() {
return new ListItr(0);
}
Itr的成員變量中,cursor表示下一個訪問對象的索引,lastRet表示上一個訪問對象的索引,expectedModCount表示對ArrayList修改次數的期望值,初始值為modCount,而modCount是ArrayList父類AbstractList中定義的成員變量,初始值為0,在上述add()和remove()方法中,都會對modCount加1,增加修改次數。
在使用ArrayList的remove()方法進行對象刪除時,一種常見的運行時異常是ConcurrentModificationException,雖名為並發修改異常,但實際上單線程環境中也可能報出,原因就是上述expectedModCount與modCount不相等的問題。
一種常見的使用場景是通過for-each語法刪除元素:
public void removeElement(List<Integer> list) {
for (Integer x : list) {
if (x % 2 == 0) {
list.remove(x); // 調用ArrayList的remove方法
}
}
}
上節提到,for-each是一種語法糖,編譯之后依然調用了迭代器實現。而迭代器的next()方法會首先調用checkForComodification()方法檢查expectedModCount與modCount,如果不等就拋出ConcurrentModificationException。
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
所以,當上述代碼中調用ArrayList的remove刪除元素后,modCount自增,而迭代器中expectedModCount保持不變,就會拋出ConcurrentModificationException,但是,如果使用迭代器的remove()方法則不會拋出異常,為什么呢?
public void removeElement2(List<Integer> list) {
Iterator<Integer> itr = list.iterator();
while (itr.hasNext()) {
if (itr.next() % 2 == 0) {
itr.remove(); // 調用迭代器的remove方法
}
}
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet); // 調用ArrayList的remove方法
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount; // 設置expectedModCount 為modCount
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
從代碼中可以看出,其實迭代的remove方法也是調用了ArrayList的remove方法實現元素刪除,只不過在刪除元素之后設置了expectedModCount為modCount,避免checkForComodification時拋出異常。
2.2 LinkedList實現原理
鏈表按照鏈接形式可分為:單鏈表、雙鏈表和循環鏈表。單鏈表節點包含兩個域:信息域和指針域,信息域存放元素,指針域指向下一個節點,因此只支持單向遍歷。其結構如下圖所示:
相比於單鏈表,雙鏈表節點包含三個域:信息域、前向指針域和后向指針域,前向指針指向前一個節點地址,后向指針指向后一個節點地址,因此可以實現雙向的遍歷。其結構如下圖所示:
循環鏈表分為單循環鏈表和雙循環鏈表。即在普通單鏈表和雙鏈表的基礎上增加了鏈表頭節點和尾節點的相互指向。頭節點的前一個節點是尾節點,尾節點的下一個節點是頭節點。其結構如下圖所示:
LinkedList基於雙鏈表實現,插入和刪除元素的時間復雜度是O(1),支持這種實現的基礎數據結構是LinkedList中定義的靜態內部類Node:
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的操作方法都是基於Node節點特性設計的。
2.2.1 插入/刪除元素
在實現上,由於Deque接口同時具有隊列(雙向)和棧的特性,LinkedList實現了Deque接口,使得LinkedList能同時支持鏈表、隊列(雙向)和棧的操作。其插入/刪除方法如下表所示:
方法 | 鏈表 | 隊列 | 棧 |
---|---|---|---|
添加 | add(int index, E element) | offer(E e) | push(E e) |
刪除 | remove(int index) | E poll() | E pop() |
三者的差別在於,offer在鏈表末尾插入元素,調用linkLast實現;push在鏈表頭部插入元素,調用linkFirst實現;而add在指定位置插入元素,根據位置判斷調用linkLast或linkBefore方法。這里重點關注linkLast、linkFirst和linkBefore的實現。
private void linkFirst(E e) {
final Node<E> f = first;
// 創建新節點,其prev節點為null,元素值為e,next結點為之前的first節點
final Node<E> newNode = new Node<>(null, e, f);
first = newNode;
// 如果初始列表為空,則將尾結點設置為當前新節點
if (f == null)
last = newNode;
else
f.prev = newNode;
// 增加鏈表數量及修改次數
size++;
modCount++;
}
void linkLast(E e) {
final Node<E> l = last;
// 創建新節點,其prev結點為之前的尾節點,元素值為e,next節點為null
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
// 如果初始列表為空,則將頭結點設置為當前新節點
if (l == null)
first = newNode;
else
l.next = newNode;
// 增加鏈表數量及修改次數
size++;
modCount++;
}
void linkBefore(E e, Node<E> succ) {
// 創建succ的prev節點引用
final Node<E> pred = succ.prev;
// 創建新節點,其prev節點為succ的prev節點,元素值為e,next節點為succ
final Node<E> newNode = new Node<>(pred, e, succ);
// 修改原succ節點的prev指向
succ.prev = newNode;
// 如果succ為頭節點,則設置新節點為頭節點
if (pred == null)
first = newNode;
else
pred.next = newNode;
// 增加鏈表數量及修改次數
size++;
modCount++;
}
以上代碼中的注釋對linkLast、linkFirst和linkBefore的實現進行了詳細的說明,其核心原理便是初始化新節點,並重新鏈接與原鏈表中元素的關系。remove、poll和pop在刪除元素時調用了與插入操作相反的方法unlinkFirst、unlinkLast和unlink,由於實現原理類似,這里不再贅述。
2.2.2 查找及迭代器
從上節分析可以看出,LinkedList的插入/刪除操作只需要改變節點元素的鏈接指向,因此效率較高。但其查找元素需要從頭節點或尾節點開始對集合進行遍歷,相比於ArrayList基於數組索引,效率較低。
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;
}
}
LinkedList通過比較index與size/2(size >> 1)判斷是從頭節點還是尾節點開始遍歷,然后通過分別獲取該節點的next節點或prev節點來實現。另外,由於LinkedList本身就同時支持前向/后向移動,所以其iterator方法直接返回ListIterator實現。
public Iterator<E> iterator() {
return listIterator();
}
三、Stack / Queue模型、實現及應用
Stack和Queue在模型設計上具有相似性,其核心方法對比如下:
方法 | Stack | Queue |
---|---|---|
插入 | push(E item) | offer(E e) |
刪除 | E pop() | poll() |
查看 | E peek() | E peek() |
兩者的核心區別在於Stack是先進后出(FILO),數據操作在一端進行;而Queue是先進先出(FIFO),在一端存儲,另一端取出(Deque繼承自Queue,支持雙向存儲/取出)。
從上節可知,LinkedList是Queue(Deque)模型最常見的一種實現。下面通過一個實例,說明如何利用LinkedList的隊列特征來模擬單向循環鏈表。比如有一個任務集合,任務有是否完成兩種狀態,初始狀態均為未完成,需要實現從第一個任務開始的單向循環遍歷,如果當前任務完成,則不再參與遍歷,直到所有任務完成。
private Task getNextUnCompleteTask(LinkedList<Task> taskList) {
Task task = taskList.peek();
if (task == null) {
return null;
}
if (task.isComplete()) {
taskList.poll();
} else {
taskList.offer(taskList.poll());
}
return taskList.peek();
}
上述代碼通過將未完成的任務重新添加至隊尾,從而在循環調用getNextUnCompleteTask方法時,實現對未完成任務的循環遍歷。