資料結構與演算法-連結串列(上)

BackSlash發表於2018-12-19

陣列是軟體開發過程中非常重要的一種資料結構,但是陣列至少有兩個侷限:

  • 編譯期需要確定元素大小
  • 陣列在記憶體中是連續的,插入或者刪除需要移動陣列中其他資料
陣列適合處理確定長度的,對於插入或者刪除不敏感的資料。如果資料是頻繁變化的,就需要選擇其他資料結構了。連結串列是一種邏輯簡單的、實用的資料結構,幾乎被所有程式設計語言支援。我們從最簡單的鏈式結構開始,根據需求的變化一步步改進,滿足產品需求。


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)級別的操作可以接受,對於更加複雜的資料,需要更加複雜的資料結構進行處理。出於學習的態度,可以繼續研究,畢竟有句話叫做-厚積薄發。

資料結構與演算法-連結串列(下)


相關文章