2.3 線性表的鏈式儲存結構(連結串列)
基本概念和特點
連結串列的定義
-線性表的鏈式儲存結構稱之為連結串列(linked list)。連結串列包括兩部分構成:資料域和指標域。資料域儲存資料元素,指標域描述資料元素的邏輯關係。
- 連結串列通常使用帶頭結點的表示。指向頭結點的指標稱之為頭指標(head pointer),指向最後一個結點(也就是尾結點)的指標稱之為尾指標(tail pointer)。
- 連結串列不具備隨機存取特性。
- 儲存密度(storage density)是指資料的體積佔結點總體積的比。連結串列的儲存密度總小於1(因為有指標域)。
連結串列的型別及其特點
- 連結串列根據指標域的指向,分為單鏈表、雙鏈表。單鏈表只能通過前驅找到後繼,不能通過後繼找到前驅。雙鏈表使用雙向指標域,方便了反向查詢,是一種時空權衡的做法。
- 根據尾結點和頭結點能否構成聯絡,分為普通連結串列和迴圈連結串列。
單鏈表的表示和基本操作
單鏈表的表示
因為連結串列本身依然是屬於線性結構,也是線性表,因此類介面與線性表無異。線性表具備的方法都應該滿足(但是不意味著能夠以最高效率實現)。
下面是使用C++語言的類描述。開始定義兩個巨集來表示初始化是使用頭插法還是尾插法。
定義結點採用了類的巢狀,因為要保證結點的資料型別和主類一致,使用巢狀類能夠避免很多資料型別不相容的情況。STL就是這麼做的。除了這種方法能夠實現資料結構的泛型,對於C,因為沒有模板,而資料結構通常作為底層的庫,必須具備泛型特性,因此通常直接在記憶體中根據大小存放資料,實際使用時,再將其指標進行強制型別轉換為需要的指標。例如下面給出的php 7的連結串列結點實現(使用變長結構),就是基於這樣的考慮。
/* php v7.1.4 zend_llist.h */
typedef struct _zend_llist_element {
struct _zend_llist_element *next;
struct _zend_llist_element *prev;
char data[1]; /* Needs to always be last in the struct */
} zend_llist_element;
連結串列的終止通常用巨集NULL來表示,對於C++11,可以使用關鍵字nullptr來實現空指標。下面的程式碼都是使用了這一特性的,對於不支援C++11的編譯器,可以使用巨集#define nullptr NULL來使用下面的程式碼。
我們需要維護其頭指標來標識整個連結串列,因此設定了_head私有成員變數。_length是為了簡化求長度的操作,這樣就不必在使用length()方法時,遍歷整個表了。只需要額外的4位元組開銷。這種方法的壞處在於,不能夠在類外隨便操作連結串列結點(因為無法更新_length的值)。不過此處的連結串列依然作為線性表的表示(而算一個玩具),所以並無大礙。
//定義建構函式採用頭插法還是尾插法
#define INIT_INSERT_HEAD 1
#define INIT_INSERT_TAIL 0
/**
* 連結串列類
*/
template<typename _Ty>
class SinglyLList
{
public:
/**
* 連結串列結點結構體
*/
struct SinglyLListNode{
_Ty data; //資料域
SinglyLList<_Ty>::SinglyLListNode * next; //指標域
SinglyLListNode(){
next = nullptr;
}
};
private:
SinglyLList<_Ty>::SinglyLListNode * _head; //連結串列的頭指標
int _length; //連結串列的長度(元素個數)
public:
/**
* 建構函式,建立一個線性表,並用指定的資料填充線性表。
* @param _Ty init_data[] 初始輸入資料陣列
* @param int n 初始資料陣列長度
* @param int mode 採用的初始化插入方法,預設頭插法
*/
SinglyLList(_Ty init_data[], int n, int mode = INIT_INSERT_HEAD);
/**
* 預設建構函式,建立一個空的線性表
*/
SinglyLList();
/**
* 解構函式,銷燬線性表
*/
~SinglyLList();
/**
* 判斷當前線性表是否為空
* @return bool 表空返回true,否則false
*/
bool empty();
/**
* 返回當前線性表的長度
* @return int 線性表的實際長度
*/
int length();
/**
* 返回線性表中指定位置的元素
* @param int i 序號
* @return _Ty 返回元素的值
*/
_Ty & at(int i);
_Ty & operator[](int i);
/**
* 查詢線性表中指定值的元素的位置
* @param _Ty value 需要查詢的元素的值
* @return int 返回該元素的位置,0為未找到
*/
int find(_Ty value);
/**
* 將指定元素插入指定位置
* @param int i 待插入元素的位置
* @param _Ty value 待插入元素的值
* @return bool 操作成功返回true,否則false
*/
bool insert(int i, _Ty value);
/**
* @param int i 需要刪除的元素的位置
* @return bool 操作成功返回true,否則false
*/
bool remove(int i);
};
單鏈表的建構函式
建構函式根據已有資料建立整個連結串列。這裡使用了mode引數決定採用尾插法還是頭插法。頭插法關注的是_head指標,而尾插法關注的是尾指標,因此維護一個tail是必要的。單鏈表的插入,先連結當前結點的next,將其指向下一個結點,然後將原來的前驅結點的next設定為當前插入的結點。後面的連結串列插入也是如此方法。
template<typename _Ty>
SinglyLList<_Ty>::SinglyLList(_Ty init_data[], int n, int mode){
assert(init_data && n >= 0);
//需要#include<cassert>
SinglyLListNode * tail;
SinglyLListNode * newnode;
tail = _head = new SinglyLListNode;
//判斷輸入資料是否為空
if(n){
for(int i = 0; i < n; i++){
newnode = new SinglyLListNode;
newnode->data = init_data[i];
//判斷使用頭插法還是尾插法
if(mode == INIT_INSERT_HEAD){
newnode->next = _head->next;
_head->next = newnode;
}else{
tail->next = newnode;
tail = newnode;
}
}
}
//如果SinglyLListNode不具備建構函式,下面的程式碼是必須的
//if(mode == INIT_INSERT_TAIL)
// tail->next = nullptr;
_length = n;
}
無引數的建構函式用於建立一個空表,只需要將_length設定為零,建立一個頭結點就可以了
template<typename _Ty>
SinglyLList<_Ty>::SinglyLList(){
_head = new SinglyLListNode;
_length = 0;
}
解構函式
解構函式用來銷燬整個連結串列,並釋放記憶體空間。主要的方法就是遍歷整個表,逐個銷燬結點。需要注意的是,不能將指標弄丟了,否則會造成記憶體洩漏。在具體操作過程中,就是需要將當前結點的next先儲存,而後才能釋放當前結點。另外,不能忘記釋放頭結點。
template<typename _Ty>
SinglyLList<_Ty>::~SinglyLList(){
SinglyLListNode * tnode, * nnode = _head;
while(nnode != nullptr){
tnode = nnode->next;
delete nnode;
nnode = tnode;
}
}
判斷連結串列是否是空表
直接判斷_length域就可以了。判斷_head->next是否為nullptr也是可以的。
template<typename _Ty>
bool SinglyLList<_Ty>::empty(){
return _length == 0;
}
得到連結串列的長度
考慮設定_length域的意義,直接返回_length域就可以了,否則需要遍歷整個連結串列。
template<typename _Ty>
int SinglyLList<_Ty>::length(){
return _length;
}
根據邏輯序號得到元素
鏈式儲存結構不具備直接得到序號的能力,因此需要在遍歷過程中自行維護一個計數器count,表示當前結點的序號。注意2.1節線性表的定義中,邏輯序從1開始,因此可以假定沒有儲存實際資料的頭指標的邏輯序為0,以簡化操作。實際上從第一個結點以1計算下標,也是正確的。
template<typename _Ty>
_Ty & SinglyLList<_Ty>::at(int i){
assert(i > 0 && i <= _length);
int count = 0;
SinglyLListNode * pnode = _head;
while(count != i){
pnode = pnode->next;
count++;
}
return pnode->data;
}
為了使邏輯序更清晰,像順序表一樣也過載operator[]。
template<typename _Ty>
_Ty & SinglyLList<_Ty>::operator[](int i){
return this->at(i);
}
查詢指定元素的邏輯序
查詢指定元素,一樣需要維護一個計數器。一旦找到,則返回其邏輯序,反之,返回0。對於連結串列,實際上返回邏輯序,並無太大用途,單鏈表更傾向返回一個當前結點的前驅,以便於進行插入和刪除操作。後面的改進會提到這一點,以及如何利用這一點提高某些演算法的效率。
template<typename _Ty>
int SinglyLList<_Ty>::find(_Ty value){
int count = 1;
SinglyLListNode * pnode = _head->next;
while(pnode != nullptr){
if(pnode->data == value)
return count;
pnode = pnode->next;
count++;
}
return 0;
}
插入元素(結點)
插入元素,首先根據邏輯序找到適當的位置。單鏈表不能夠通過某一結點得到前驅,因此需要插入到第_length需要自增。
template<typename _Ty>
bool SinglyLList<_Ty>::insert(int i, _Ty value){
assert(i > 0 && i <= _length + 1);
int count = 0;
//先查詢到適當的位置(需要插入的位置的前一個結點)
SinglyLListNode * pnode = _head;
while(count != i - 1){
pnode = pnode->next;
count++;
}
SinglyLListNode * newnode = new SinglyLListNode;
newnode->data = value;
newnode->next = pnode->next;
pnode->next = newnode;
_length++;
return true;
}
刪除結點
刪除結點同插入較為類似,也要查詢實際結點的前驅。別忘了_length自減。
template<typename _Ty>
bool SinglyLList<_Ty>::remove(int i){
assert(i > 0 && i <= _length);
int count = 0;
//先查詢到適當的位置(需要插入的位置的前一個結點)
SinglyLListNode * pnode = _head;
while(count != i - 1){
pnode = pnode->next;
count++;
}
SinglyLListNode * tmp = pnode->next;
pnode->next = pnode->next->next;
delete tmp;
_length--;
return true;
}
測試程式碼
int main(){
int a[] = {10, 11, 12, 13, 14};
SinglyLList<int> s(a, 5);
int i;
for(i = 1; i <= 4; i++)
s.insert(1, i);
printf(s.empty() ? "SinglyLList Is empty.\n" : "SinglyLList Not Empty.\n");
for(i = 1; i <= s.length(); i++)
printf("%d ", s.at(i));
printf("\n");
s.remove(1);
for(i = 1; i <= s.length(); i++)
printf("%d ", s.at(i));
printf("\n");
s.remove(3);
for(i = 1; i <= s.length(); i++)
printf("%d ", s.at(i));
s.insert(3, 5);
s.insert(3, 6);
printf("\nLength => %d\n", s.length());
for(i = 1; i <= s.length(); i++)
printf("%d ", s.at(i));
printf("SinglyLList[2] => %d\n", s[2]);
printf("5 is Found At => %d\n", s.find(5));
getchar();
return 0;
}
輸出
SinglyLList Not Empty.
4 3 2 1 14 13 12 11 10
3 2 1 14 13 12 11 10
3 2 14 13 12 11 10
Length => 9
3 2 6 5 14 13 12 11 10 SinglyLList[2] => 2
5 is Found At => 4
一些改進
很多基於連結串列的演算法是先查詢再進行插入/刪除操作,實際上,對於連結串列的底層直接操作是十分必要的。
因此可以做一些改進,直接提供_head結點,並將length()方法的返回值變成引用型別,這樣就可以很容易在外部直接操作連結串列了。
更好的做法是採用在2.1節提到過的迭代器。對於非順序儲存結構而言,因為不能進行隨機存取操作,故使用邏輯序並不能帶來操作上的高效性,直接使用物理指標又破壞了資料結構的封裝性。因此像STL的做法,採用迭代器進行包裝,通過迭代器直接維護相關地址資訊,在主類中直接就可以對迭代器而非邏輯序號進行操作,既隱藏了連結串列的物理實現,避免直接操作連結串列導致錯誤,也能夠充分利用連結串列特點,避免反覆從頭查詢的壞處。
使用迭代器的具體做法,可以參考侯捷的《STL原始碼剖析》。
單鏈表應用的一些例子
基於單鏈表設計演算法,有兩種思路:
- 先查詢再進行相關操作(插入/刪除);
- 使用建表操作,對資料進行處理。
下面分別舉兩個例子。將函式作為類公有成員,這樣可以直接操作連結串列類的私有成員變數。
連結串列逆序
已知一個鏈式儲存的線性表
例如1->2->3->4->5->NULL應該變成5->4->3->2->1。時間複雜度要求為
提到逆序問題不難想到直接利用連結串列的頭插法解決。於是程式碼就很簡單了。之前考慮使用C++11的auto特性,但是不便於體現資料結構的特點,並且此處並不算繁雜,因此不用,直接寫SinglyLListNode *。
//類宣告略
/**
* 連結串列逆序演算法
*/
template<typename _Ty>
void SinglyLList<_Ty>::reverse(){
SinglyLListNode * p = _head->next;
while(p != nullptr){
SinglyLListNode * q = p->next;
p->next = _head->next;
_head->next = p;
p = q;
}
return;
}
連結串列排序
給定一個鏈式儲存結構
例如5->4->3->1->2->NULL
應該得到1->2->3->4->5->NULL。
template<typename _Ty>
void SinglyLList<_Ty>::sort(){
SinglyLListNode * p = _head->next;
_head->next = nullptr;
while(p != nullptr){
SinglyLListNode * q1 = _head->next; //作為比較指標
SinglyLListNode * q2 = _head; //q1的前驅指標
SinglyLListNode * r = p->next; //儲存下一個結點的指標
while(q1 != nullptr){
if(q1->data > p->data)
break;
q2 = q1;
q1 = q1->next;
}
q2->next = p;
p->next = q1;
p = r;
}
return;
}
下面是一種空間複雜度為comp_func的演算法)的一種演算法。其核心思想在於用一個臨時陣列elements儲存所有結點的指標,然後就可以使用陣列排序演算法comp_func對elements進行排序,排序完成後,恢復指標域的指向(根據elements[]的已經排序的特點)即可。雖然連結串列也可以直接實現
/* php v7.1.4 zend_llist.c */
ZEND_API void zend_llist_sort(zend_llist *l, llist_compare_func_t comp_func)
{
size_t i;
zend_llist_element **elements;
zend_llist_element *element, **ptr;
if (l->count <= 0) {
return;
}
elements = (zend_llist_element **) emalloc(l->count * sizeof(zend_llist_element *));
ptr = &elements[0];
for (element=l->head; element; element=element->next) {
*ptr++ = element;
}
zend_sort(elements, l->count, sizeof(zend_llist_element *),
(compare_func_t) comp_func, (swap_func_t) zend_llist_swap);
l->head = elements[0];
elements[0]->prev = NULL;
for (i = 1; i < l->count; i++) {
elements[i]->prev = elements[i-1];
elements[i-1]->next = elements[i];
}
elements[i-1]->next = NULL;
l->tail = elements[i-1];
efree(elements);
}
雙鏈表
雙鏈表由於多了一個prev域,因此在插入刪除方面比單鏈表複雜了一些。為了與單鏈表一致,通常情況也是找需要的結點的前驅。事實上,找本身也是可以的,因為雙鏈表可以隨意尋找其前驅和後繼。總之,原則就是,只要不把指標弄丟了,都是OK的。
以插入為例,已知雙鏈表的某一結點為p,則在p之後插入一個結點s,需要修改4個指標域:
s->next = p->next; //①
p->next->prev = s; //②
s->prev = p; //③
p->next = s; //④
以不弄丟結點為原則,①②是可以交換的,③④也是可以交換的。然而①②作為整體不能與③④交換,因為如果交換,那麼p的後繼就被弄沒了(畫個圖應該很清晰)。
當然,如果在p的前面插入一個結點s,就應該先將s連結到p->prev上了。畫個圖同樣很清晰為什麼。
迴圈連結串列
迴圈連結串列的特點在於,能夠根據尾指標找到頭指標。因此,對於需要同時頻繁操作首尾元素的情形,比如實現一個雙端佇列deque,雙向迴圈連結串列能夠提供較大的效率。STL的連結串列容器,為了提供諸如push_front,push_back的操作,使用了雙向迴圈連結串列實現。
除此之外,迴圈連結串列還可以用來很方便的模擬約瑟夫環問題。(然而實際高效求解約瑟夫環問題,是求遞推式)
Summary
- 理解連結串列的優缺點
連結串列就是線性表的鏈式儲存結構,優點是
O(1) 實現插入和刪除操作,缺點是不能隨機存取(查詢某個序號的元素O(n) )。
連結串列的儲存密度相比順序儲存結構較小,但是有更大的靈活性(能夠充分利用碎片空間) 實現連結串列這一資料結構及插入刪除操作
實現連結串列的基本操作不復雜,核心點在於如何實現遍歷。對於初學者而言與普通迴圈的終止不同,連結串列的終點在於其
next指向空指標。
連結串列的插入和刪除需要修改指標的指向,單鏈表、雙鏈表各有其不同的方法,根據已知結點的情況的不同實際操作千差萬別。其實只需要保證連結串列不斷裂,沒有結點脫離了表就可以了,畫個圖就行了,不必死記硬背先連線哪個後連線哪個。實現一些基於連結串列的基本演算法
基於連結串列的演算法,通常是基於建表的,核心就是圍繞頭插法還是尾插法。
除此之外,連結串列的另一種核心演算法就是基於查詢的。注意單鏈表必須去查詢其前驅結點,而雙鏈表通常基於習慣,也是查詢其前驅結點。
線性表的大多數演算法,連結串列都能夠實現。
