資料結構與演算法-連結串列(上)
陣列是軟體開發過程中非常重要的一種資料結構,但是陣列至少有兩個侷限:
- 編譯期需要確定元素大小
- 陣列在記憶體中是連續的,插入或者刪除需要移動陣列中其他資料
1、單向連結串列
單向連結串列是由一個個節點組成的,每個節點是一種資訊集合,包含元素本身以及下一個節點的地址。節點在記憶體中是非連續分佈的,在程式執行期間,根據需要可以動態的建立節點,這就使得連結串列的長度沒有邏輯上的限制,有限制的是堆的大小。在單向連結串列中,每個節點中儲存著下一個節點的地址,就像這樣:事實上,我們更加關注的是基於資料結構的演算法,連結串列是一種簡單的資料組織方式,適合中等數量的資料,我們考察連結串列的新增、刪除、查詢即可,更加複雜的操作需求最好使用更加高階的資料結構。
首先定義連結串列:
#ifndef INT_LINKED_LIST
#define INT_LINKED_LIST
class Node {
public:
//建構函式,建立一個節點
Node(int el = 0, Node* ptr = nullptr) {
info = el;
next = ptr;
}
//節點的值
int info;
//下一個節點地址
Node* next;
};
class NodeList {
public:
//建構函式,建立一個連結串列,用於管理節點
NodeList() {
head = tail = nullptr;
}
//節點插入到頭部
void addToHead(int);
//節點插入到尾部
void addToTail(int);
//刪除頭部節點
int deleteFromHead();
//刪除尾部節點
int deleteFromTail();
//刪除指定節點
void deleteNode(int);
private:
//頭指標、尾指標
Node *head, *tail;
};
#endif
複製程式碼
這裡定義了兩個class
class
具有5個成員方法,分別代表著節點的新增、刪除、查詢,我們來考察下這3種操作在連結串列中的表現。
單向連結串列的操作比較簡單,這裡直接使用動圖來代替程式碼,更加易於理解。
假設已有連結串列如下:
- 節點插入到頭部
節點插入到頭部的邏輯比較簡單,演算法複雜度能在固定時間O(1)
內完成,也就是說,無論連結串列中有多少個節點,該函式所執行操作的數目都不會超過某個常數c。注意,該操作的實現依賴head
指標,否則無法確定頭結點的地址,那麼演算法的複雜度將會大大增加。
- 節點插入到尾部
節點插入到尾部的邏輯和插入到頭部相似,演算法複雜度也是O(1)
,區別在於該操作的實現依賴tail
指標,否則無法確定尾節點的地址,那麼演算法的複雜度將會大大增加。
- 刪除頭部節點
刪除頭部節點操作的演算法複雜度也是O(1)
,該操作依賴head
指標,通過head
指標可以直接獲取到下個節點的地址,所以複雜度很低。
- 刪除尾部節點
注意這裡,刪除尾部節點的演算法複雜度是O(n)
,相比於前面的O(1)
,提升了兩個量級。原因在於我們需要一個臨時指標p,從頭結點一直遍歷到倒數第二個節點。因為刪除尾節點之後,tail指標需要向頭結點方向移動一次,但是在連結串列中不能直接獲取到倒數第二個節點的地址,只能依靠遍歷的方式,這就導致演算法複雜度上升為O(n)
。在單向連結串列中沒有更好的解決方式了,在後面我們需要改進連結串列結構避免這種情況。
- 刪除指定節點
刪除指定節點的演算法複雜度也是不盡人意,在最好的情況下花費O(1)
的時間,在最壞和平均情況下則是O(n)
。通過動態圖可以發現,我們定義P指標指向目標節點,定義Q節點指向目標節點的前驅節點。這兩個變數的存在意義在於修正單向連結串列的指向,是不可或缺的。
基於單向連結串列的某些操作的演算法複雜度無法滿足我們的需求,這裡主要指刪除尾部節點以及刪除指定節點,它們的平均複雜度達到了O(n)
,相比於O(1)增加了兩個量級。為了改進演算法,我們需要修改連結串列的結構。對於刪除尾部節點來說,瓶頸在於無法直接獲取尾節點的前驅節點地址,我們可以為節點加上一個指向前節點的指標來解決,這就是所謂的雙向連結串列。
2、雙向連結串列
雙向連結串列是這個樣子:
首先是定義:
#ifndef INT_LINKED_LIST
#define INT_LINKED_LIST
class Node {
public:
//建構函式,建立一個節點
Node(int el = 0, Node* p = nullptr, Node* q = nullptr) {
info = el;
pre = p;
next = q;
}
//節點的值
int info;
//前一個節點地址
Node* pre;
//下一個節點地址
Node* next;
};
class NodeList {
public:
//建構函式,建立一個連結串列,用於管理節點
NodeList() {
head = tail = nullptr;
}
//節點插入到頭部
void addToHead(int);
//節點插入到尾部
void addToTail(int);
//刪除頭部節點
int deleteFromHead();
//刪除尾部節點
int deleteFromTail();
//刪除指定節點
void deleteNode(int);
private:
//頭指標、尾指標
Node *head, *tail;
};
#endif複製程式碼
基於雙向連結串列的操作和單向連結串列非常相似,我們是從單向連結串列中擴展出雙向連結串列的,目的是改進刪除尾部節點的演算法。
- 刪除尾部節點
可以看到刪除尾部節點的演算法複雜度已經降至O(1)
,事實上pre
指標不僅僅簡化了刪除尾節點操作,對於其他O(1)
的操作也有簡化,因為有了pre
指標,有些臨時指標就沒必要定義了。
儘管如此,我們還是增加了空間的使用程度才降低了時間上的消耗,本質上是空間換取時間的做法。對於現代軟體開發來講,硬體已經不是主要瓶頸,一些空間上的代價是值得的。
也許有人瞭解過所謂的迴圈單向連結串列、迴圈雙向連結串列,它們到底是什麼東西呢?
迴圈單向連結串列和單向連結串列的差別:
差別就在於尾節點的next指標迴圈指向了頭結點,這時候head指標就沒必要存在了,如果繼續定義head指標,只是更加方便一些,但它已經不是不可或缺的了。
迴圈雙向連結串列和雙向連結串列的差別:
同樣的道理,head
指標根據需要新增。迴圈連結串列和普通連結串列沒有本質的差別,可以根據需要自行選擇。
到目前為止,我們還有一個問題沒有解決,那就是刪除指定節點。該操作本質上是查詢問題,為了優化查詢演算法,我們需要繼續對連結串列結構進行改動。事實上,上述連結串列已經足夠滿足需求了,因為我們假設物件是中等數量的資料,O(n)
級別的操作可以接受,對於更加複雜的資料,需要更加複雜的資料結構進行處理。出於學習的態度,可以繼續研究,畢竟有句話叫做-厚積薄發。