順序表與鏈表是非常基本的數據結構,它們可以被統稱為線性表。
線性表(Linear List)是由 n(n≥0)個數據元素(結點)a[0],a[1],a[2]…,a[n-1] 組成的有限序列。
順序表和鏈表,是線性表的不同存儲結構。它們各自有不同的特點和適用范圍。針對它們各自的缺點,也有很多改進的措施。
一、順序表
順序表一般表現為數組,使用一組地址連續的存儲單元依次存儲數據元素,如圖 1 所示。它具有如下特點:
- 長度固定,必須在分配內存之前確定數組的長度。
- 存儲空間連續,即允許元素的隨機訪問。
- 存儲密度大,內存中存儲的全部是數據元素。
- 要訪問特定元素,可以使用索引訪問,時間復雜度為 $O(1)$。
- 要想在順序表中插入或刪除一個元素,都涉及到之后所有元素的移動,因此時間復雜度為 $O(n)$。

圖 1 順序表
順序表最主要的問題就是要求長度是固定的,可以使用倍增-復制的辦法來支持動態擴容,將順序表變成“可變長度”的。
具體做法是初始情況使用一個初始容量(可以指定)的數組,當元素個數超過數組的長度時,就重新申請一個長度為原先二倍的數組,並將舊的數據復制過去,這樣就可以有新的空間來存放元素了。這樣,列表看起來就是可變長度的。
一個簡單的實現如下所示,初始的容量為 4。
#include <string.h>
struct sqlist {
int *items, size, capacity;
sqlist():size(0), capacity(4) {
// initial capacity = 4
items = new int[capacity];
}
void doubleCapacity() {
capacity *= 2;
int* newItems = new int[capacity];
memcpy(newItems, items, sizeof(int)*size);
delete[] items;
items = newItems;
}
void add(int value) {
if (size >= capacity) {
doubleCapacity();
}
items[size++] = value;
}
};
這個辦法不可避免的會浪費一些內存,因為數組的容量總是倍增的。而且每次擴容的時候,都需要將舊的數據全部復制一份,肯定會影響效率。不過實際上,這樣做還是直接使用鏈表的效率要高,具體原因會在下一節進行分析。
二、鏈表
鏈表,類似它的名字,表中的每個節點都保存有指向下一個節點的指針,所有節點串成一條鏈。根據指針的不同,還有單鏈表、雙鏈表和循環鏈表的區分,如圖 2 所示。

圖 2 鏈表
單鏈表是只包含指向下一個節點的指針,只能單向遍歷。
雙鏈表即包含指向下一個節點的指針,也包含指向前一個節點的指針,因此可以雙向遍歷。
循環單鏈表則是將尾節點與首節點鏈接起來,形成了一個環狀結構,在某些情況下會非常有用。
還有循環雙鏈表,與循環單鏈表類似,這里就不再贅述。
由於鏈表是使用指針將節點連起來,因此無需使用連續的空間,它具有以下特點:
- 長度不固定,可以任意增刪。
- 存儲空間不連續,數據元素之間使用指針相連,每個數據元素只能訪問周圍的一個元素(根據單鏈表還是雙鏈表有所不同)。
- 存儲密度小,因為每個數據元素,都需要額外存儲一個指向下一元素的指針(雙鏈表則需要兩個指針)。
- 要訪問特定元素,只能從鏈表頭開始,遍歷到該元素,時間復雜度為 $O(n)$。
- 在特定的數據元素之后插入或刪除元素,不涉及到其他元素的移動,因此時間復雜度為 $O(1)$。雙鏈表還允許在特定的數據元素之前插入或刪除元素。
在上一節說到,利用倍增-復制的辦法,同樣可以讓順序表長度可變,而且效率比鏈表還要好,下面就簡單的實現一個單鏈表來驗證這一點,至於元素插入的順序就不要在意了。
#include <stdio.h>
#include <time.h>
struct node {
int value;
node *next;
};
struct llist {
node *head;
void add(int value) {
node *newNode = new node();
newNode->value = value;
newNode->next = head;
head = newNode;
}
};
int main() {
int size = 100000;
sqlist list1;
llist list2;
long start = clock();
for (int i = 0;i < size;i++) {
list1.add(i);
}
long end = clock();
printf("sequence list: %d\n", end - start);
start = clock();
for (int i = 0;i < size;i++) {
list2.add(i);
}
end = clock();
printf("linked list: %d\n", end - start);
return 0;
}
在我的電腦上,鏈表的耗時大約是順序表的 4~8 倍。會這樣,是因為數組只需要很少的幾次大塊內存分配,而鏈表則需要很多次小塊內存分配,內存分配操作相對是比較慢的,因而大大拖慢了鏈表的速度。這也是為什么會出現內存池。
因此,鏈表並不像理論分析的那樣美好,在實際應用中要受很多條件制約,一般情況下還是安心用順序表的好。
三、靜態鏈表
為了彌補鏈表在內存分配上的不足,出現了靜態鏈表這么一個折中的辦法。靜態鏈表比較類似於內存池,它會預先分配一個足夠長的數組,之后鏈表節點都會保存在這個數組里,這樣就不需要頻繁的進行內存分配了。
當然,這個方法的缺點是需要預先分配一個足夠長的數組,肯定會導致內存的浪費。數組不夠長到不是什么大不了的,使用第一節的動態擴容方法就是了。
靜態鏈表一般是由兩個鏈表組成,一個保存數據的鏈表,一個空閑節點的鏈表,如圖 3 所示。

圖 3 靜態鏈表
當需要向鏈表中添加節點時,就從空閑鏈表中摘下一個使用。從鏈表中刪除節點時,就將被刪除的節點歸還到空閑鏈表中。
在實現上,由於靜態鏈表的節點都是存儲在數組中的,所以經常使用數組索引代替指針,如果數組擴容了,也不會影響現有的節點。下面簡單的實現了一個靜態雙向鏈表,沒有添加動態擴容的能力。
struct snode {
int value;
int prev;
int next;
};
struct sllist {
snode *nodes;
int head, freeHead;
sllist():head(-1), freeHead(0) {
// 初始化空閑鏈表,靜態分配長度為 100。
nodes = new snode[100];
for (int i = 0;i < 100;i++) {
nodes[i].next = i + 1;
}
}
void add(int value) {
// 從空閑鏈表中摘取節點。
int newNode = freeHead;
freeHead = nodes[freeHead].next;
nodes[newNode].value = value;
nodes[newNode].prev = -1;
nodes[newNode].next = head;
if (head != -1) {
nodes[head].prev = newNode;
}
head = newNode;
}
void remove(snode node) {
int idx = head;
if (node.prev == -1) {
head = node.next;
} else {
idx = nodes[node.prev].next;
nodes[node.prev].next = node.next;
}
if (node.next != -1) {
nodes[node.next].prev = node.prev;
}
// 將節點歸還空閑鏈表。
nodes[idx].next = freeHead;
freeHead = idx;
}
};
靜態鏈表的效率幾乎跟數組一樣,極大的提升了鏈表的效率。不過因為鏈表的效率受內存分配影響,不同的語言可能有不同的表現,具體情況還需要實驗分析才可以。
四、塊狀鏈表
塊狀鏈表則是鏈表和順序表的結合體,將多個順序表以鏈表連接起來,如圖 4 所示。

圖 4 塊狀鏈表
這種數據結構的優點是結合了順序表和鏈表的優點,長度可變,而且插入、刪除也比較迅速(不必移動全部元素,只需要移動某一個或幾個塊中的元素),時間復雜度約為 $O(\sqrt n)$,內存的占用也不會像鏈表那么多。
但是缺點也很明顯,就是實現起來過於復雜,要想讓時間復雜度達到 $O(\sqrt n)$,需要令塊的個數和每塊中存儲的元素個數都接近 $\sqrt n$ 才行,這進一步限制了塊狀鏈表的應用。
STL 中的 deque 結構比較類似於塊狀鏈表,只不過它記錄每一塊使用的仍然是數組,而不是鏈表。同時 deque 只允許在兩端進行插入和刪除,實現上就容易很多。
五、跳表
跳表是針對有序鏈表進行優化的一種數據結構。它通過為鏈表節點隨機化的添加前進鏈接,得以快速的跳過部分列表,如圖 5 所示。

圖 5 跳表
跳表會分為很多層,最底層就是普通的鏈表,高層則是用來快速獲取后面的節點的。查找的時候,會從頂層的頭節點開始向后查找,直到找到小於或等於目標的最后一個節點(鏈表是有序的,這是前提條件)。如果未能找到元素,則從下層鏈表接着找,最底層的普通鏈表保證一定能找到目標元素。
以上圖為例,現在要查找元素 d4,那么首先會沿着頂層鏈表查找,找到 d3,接着沿着第二層鏈表查找,下一個元素是 d5 > d4,那么就只能沿着底層鏈表查找,成功找到元素 d4。動畫演示可見圖 6。

圖 6 跳表查找過程
跳表的效率還是很高的,可以比擬二叉查找樹($O(\log n)$),而且實現起來比二叉查找樹要簡單一些,屬於以空間換時間的數據結構(需要很多額外的鏈表指針)。
我目前也沒有仔細閱讀過跳表的論文(Pugh W. Skip lists: a probabilistic alternative to balanced trees[J]. Communications of the ACM, 1990, 33(6): 668-676.),所以不是很明白具體的實現,以上內容僅供參考,跳表的論文和代碼可以從這里下載。
