1. 程式人生 > > 2.3 線性表的鏈式儲存結構(連結串列)

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;
}

插入元素(結點)

插入元素,首先根據邏輯序找到適當的位置。單鏈表不能夠通過某一結點得到前驅,因此需要插入到第i個位置,實際上需要找邏輯序為i1的結點,然後將新的結點放在原先的第i1個結點和第i個結點之間。注意自行維護的_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原始碼剖析》。

單鏈表應用的一些例子

基於單鏈表設計演算法,有兩種思路:
- 先查詢再進行相關操作(插入/刪除);
- 使用建表操作,對資料進行處理。
下面分別舉兩個例子。將函式作為類公有成員,這樣可以直接操作連結串列類的私有成員變數。

連結串列逆序

已知一個鏈式儲存的線性表s=(a1,a2,...,an),將其逆序,得到s=(an,an1,...,a1)
例如1->2->3->4->5->NULL應該變成5->4->3->2->1。時間複雜度要求為O(n),空間複雜度要求O(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;
}

連結串列排序

給定一個鏈式儲存結構s=(a1,a2,...,an),將其所有元素a1an按從小到大的次序排列。要求演算法時間複雜度為O(n2),空間複雜度O(1)
例如5->4->3->1->2->NULL
應該得到1->2->3->4->5->NULL

O(n2)複雜度排序,插入排序、選擇排序等都可以滿足。連結串列的插入刪除操作比較方便,寫起來也比較自然。下面就是一個插入排序的解法,其實本質也是建立新連結串列的過程,將原表劃分為已經有序的和沒有序的兩部分,不斷掃描沒有序的表,維護前面有序表的性質即可。在2.5節有序表就是這種插入方法。注意單鏈表的關鍵在於找前驅。

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;
}

下面是一種空間複雜度為O(n),時間複雜度可以降到O(nlogn)(取決於comp_func的演算法)的一種演算法。其核心思想在於用一個臨時陣列elements儲存所有結點的指標,然後就可以使用陣列排序演算法comp_funcelements進行排序,排序完成後,恢復指標域的指向(根據elements[]的已經排序的特點)即可。雖然連結串列也可以直接實現O(nlogn)的演算法,不過顯然沒有用陣列實現簡潔,並且O(n)的空間複雜度應該也是可以接受的。

/* 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_frontpush_back的操作,使用了雙向迴圈連結串列實現。
除此之外,迴圈連結串列還可以用來很方便的模擬約瑟夫環問題。(然而實際高效求解約瑟夫環問題,是求遞推式)

Summary

  • 理解連結串列的優缺點

    連結串列就是線性表的鏈式儲存結構,優點是O(1)實現插入和刪除操作,缺點是不能隨機存取(查詢某個序號的元素O(n))。
    連結串列的儲存密度相比順序儲存結構較小,但是有更大的靈活性(能夠充分利用碎片空間)

  • 實現連結串列這一資料結構及插入刪除操作

    實現連結串列的基本操作不復雜,核心點在於如何實現遍歷。對於初學者而言與普通迴圈的終止不同,連結串列的終點在於其next指向空指標。
    連結串列的插入和刪除需要修改指標的指向,單鏈表、雙鏈表各有其不同的方法,根據已知結點的情況的不同實際操作千差萬別。其實只需要保證連結串列不斷裂,沒有結點脫離了表就可以了,畫個圖就行了,不必死記硬背先連線哪個後連線哪個。

  • 實現一些基於連結串列的基本演算法

    基於連結串列的演算法,通常是基於建表的,核心就是圍繞頭插法還是尾插法。
    除此之外,連結串列的另一種核心演算法就是基於查詢的。注意單鏈表必須去查詢其前驅結點,而雙鏈表通常基於習慣,也是查詢其前驅結點。
    線性表的大多數演算法,連結串列都能夠實現。

.