數據結構與算法專題之線性表——鏈表(一)單鏈表

  本文是數據結構與算法專題的第一篇文章,後面會持續更新關於數據結構的文章。此係列主要針對數據結構的入門級小夥伴們,文中我會盡量使用白話的語言以及適當的代碼、圖片和例子來幫助大家理解,並提供一些自己的經驗、認識和代碼模板,希望能對大家的數據結構學習有所幫助。歡迎大傢俬信留言交流學習經驗~如果發現錯誤,也歡迎大家指正~

本章內容是線性表

  線性表是數據結構的入門級知識,它是數據的一種線性邏輯結構,關於邏輯結構,分爲以下幾點:

1. 集合,數據簡單的集合劃分結構,沒什麼特點。

2. 線性結構,元素之間存在一對一的對應關係,常見的有順序表、鏈表、棧和隊列等。

3. 樹形結構,元素之間存在一對多的關係,常見的有二叉樹。

4. 圖,元素之間有多對多的對應關係。

  本文着重介紹線性表中鏈表裏的單鏈表,其他數據結構會在後續的文章裏持續更新

  在說鏈表之前,我們先回到之前說的線性結構,線性表是典型的線性結構,它是最基本最常用也是最簡單的一種數據結構,它的特點也很容易理解,就是除了第一個和最後一個元素,中間的每個相鄰元素都是首尾相接的,看起來就像是一條“線”。而線性表的存儲方式又分爲鏈式存儲和順序存儲,這就上升到了數據在內存中的組織形式了,下面來分別介紹這兩種存儲形式在內存中表現的區別以及優缺點。

1. 順序存儲

  關於順序存儲,我們在學習數據結構之前都已經接觸過了,就是最基本的數組,爲什麼數組是順序存儲呢?我們回想一下C語言裏數組的特點,我們知道訪問數組元素可以使用數組名[下標](如arr[6])的形式,也可以使用數組名+偏移量(如arr+6)的指針形式,我們不考慮下標形式,想一下爲什麼可以使用數組名+偏移量?我們已知在C語言中,數組名即數組首元素的地址,那麼可以通過首元素地址+偏移量訪問,那麼可得知數組裏各元素在內存中是順序排列的,如下圖所示:


  這種存儲方式優缺點都很突出,比如順序結構的查詢效率很高,查詢某位置元素的複雜度爲O(1),查找指定內容的元素,也有很多算法,比如快排+二分查找,這些成熟的算法結合順序表都能得到很好的應用。但是順序表的插入和刪除卻最壞可達O(n)複雜度,原因在於添加或刪除元素,需要將該元素之後的元素整體後移或前移,最壞的情況就是你刪除第一個元素或在第一位添加元素,那麼整個順序表的元素都要移動,效率很低。而且順序表另一缺點在於內存浪費,因爲數組都是預先定義大小的,如果實際存儲元素遠小於順序表總大小,那麼剩下一大片內存只能白白浪費了。

2. 鏈式存儲

  鏈式存儲與順序存儲不同,它在內存中的組織形式是離散的,邏輯上相鄰的元素在內存中可能不會相鄰,比如說一個整型鏈式表的第一個元素可能在地址0x124而第二個元素卻在0x666上,這就是鏈式存儲的特點。由於元素是離散存儲的,所以我們不能像順序表那樣清楚地知道元素間的位置關係,所以需要引入一個指針域,使用指針域來指向下一個元素所在的位置,這樣我們就可以已知鏈式表的第一個元素,通過指針域的指向,來“順藤摸瓜”依次找到鏈式表的所有元素。以單鏈表爲例,如下圖:


  可以看出,它的相鄰元素的地址並不是連續的,它的優缺點也很明顯,可以說是與順序表是相反的模式,它的查詢效率很低,最壞達到O(n),也就是訪問最後一個元素或者要查找的元素在最後,那麼就要從頭到尾遍歷這個鏈表。但是增加和刪除就很高效了,我們把指針域比作鏈子,要刪除某個元素,只要把它前面的鏈子和後面的鏈子斷開,然後讓他倆連起來就可以了。可以實現O(1)複雜度。

  接下來進入主題,鏈表。首先基本的鏈表有三種,分別是單鏈表雙向鏈表循環鏈表,本章先介紹單鏈表,後續關於鏈表的章節會陸續介紹雙向鏈表和循環鏈表。

  單鏈表是最常見也是最簡單的一種鏈表,它的每個元素含有兩部分:值域和指針域,指針域是一個指向下一元素地址的指針,最後一個元素的指針域爲空,代表結尾,後面不會再有元素了。

  爲了使容器通用,我們這裏使用C++模板類(泛型)

  我們定義單鏈表一個結點的結構爲:

template<class T>
struct Node
{
    T data;
    Node<T> *next;
};
  建議將單鏈表的操作和屬性封裝起來,使用面向對象的方法來操作,同樣是模板類。

  我們在這裏引入單鏈表的頭結點,該結點是特殊結點,值域爲空。一個空的單鏈表只有一個頭結點,且頭結點的指針域爲NULL。引入頭結點的目的是爲了減少鏈表特殊情況而導致空指針引用錯誤,也爲了方便實現任意位置插入。

  我們將單鏈表的類結構定義如下:

template<class T>
class LinkList
{
private:
    Node<T> *head; // 頭部結點指針
    Node<T> *tail; // 尾部指針
    int cnt; // 鏈表長度
public:
    LinkList()  // 構造函數,初始化鏈表
    {
        head = new Node<T>; // 獲取一個頭結點
        head->next = NULL; // 將頭結點指針域置空
        tail = head; // 尾指針指向頭部,此時鏈表爲空
        cnt = 0;
    }
    void push_back(T elem);  // 向尾部插入(正向建表)
    void push_front(T elem); // 向頭部插入(逆序建表)
    void insert(T elem, int index);  // 向index位置插入
    void del(int index); // 刪除index位置的元素
    Node<T>* get(int index); // 獲取index位置的元素指針
    int size(); // 獲取鏈表的大小
    void each(char split); // 遍歷輸出鏈表,以split參數爲間隔
};


下面我們根據定義的結構來逐步介紹並實現單鏈表的操作

(1) 單鏈表的尾部插入操作(push_back)

  尾部插入,即正向建立鏈表,我們在定義鏈表的時候引入了尾部指針,這個指針的作用就是用來快速插入尾部元素。此指針需要保證始終指向鏈表最末端的元素。

  插入時,我們先需要申請一個Node結點,假設指向該新節點的指針爲p,要插入的元素爲elem,則現需要初始化該結點p->data=elem。由於此結點是插入到尾部,所以它插入以後就變成了最後一個元素,故需要將指針域置空,即p->next=NULL。如圖所示:


  接下來,需要將p指針指向的元素添加到tail指向的元素之後,這一步操作很簡單,看圖就能看出來,直接tail->next=p即可。如下圖所示:

  然後不要忘記,前面說過tail指針要始終保證指向最後一個元素,所以還需要將tail指向p所指的結點,即執行tail=p,如下圖:


  至此,完成了單鏈表的尾部插入操作,代碼實現如下:

template<class T>
void LinkList<T>::push_back(T elem)  // 向尾部插入(正向建表)
{
    Node<T> *p = new Node<T>;
    p->data = elem;
    p->next = NULL;
    tail->next = p;
    tail = p;
    cnt++;
}

(2) 單鏈表的頭部插入操作(push_front)

  頭部插入,即逆序建鏈表,逆序建鏈表不需要tail指針的輔助,只需要利用好head指針和頭結點即可,第一步與尾部插入的步驟一樣,先生成一個新結點,這裏就不圖示了,與尾插一致,區別在第二步,由於新結點需要插入到鏈表頭部,也就是頭結點與元素0之間,所以我們需要先將p的指針域指向元素0,保證p所指結點與除頭結點外所有結點相連,如圖:


  然後,再把頭結點的指針域指向p,使得包括頭結點在內所有結點組成線性鏈,如下圖:


  注意,這裏順序不能亂。如果我們先把頭結點指針域指向了p,那麼頭結點與元素0之間的“鏈子”就會斷開,我們就無法再找到元素0了。如下圖:

  另外,頭部插入有種特殊情況,假設插入前鏈表爲空且引入了尾指針,那麼在插入完成後需要將尾指針指向p的指向,其他情況不需要移動尾指針,如下圖(畫圖實在是費勁,有時候用^代表NULL或者空):


  至此頭部插入操作完成,代碼如下:

template<class T>
void LinkList<T>::push_front(T elem) // 向頭部插入(逆序建表)
{
    Node<T> *p = new Node<T>;
    p->data = elem;
    p->next = head->next;
    head->next = p;
    if(cnt == 0) // 無元素,移動尾指針
    {
        tail = p;
    }
    cnt++;
}

(3) 單鏈表的任意位置插入(insert)

  首先先找出任意位置插入的兩種特殊情況,假設鏈表的長度爲n,則在位置0插入和在位置n插入屬於兩種特殊情況。在位置0插入相當於在鏈表頭插入,即push_front,在位置n插入相當於鏈表尾插入(注意,並不是在n-1插入,想想爲什麼),相當於push_back。其他情況均屬於通常情況。

  假設我們已有一個含有n元素的單鏈表,現在要在位置i(非0非n)插入一個新元素p,如圖所示:


  在位置i插入,則新元素取代原i元素的位置,原i元素及後面所有元素均鏈接在新元素之後,有沒有感覺很熟悉?沒錯,跟頭部插入大同小異,我們只要把i元素前面的那個元素i-1看做是“head元素”即可。那麼如何找到元素i和i-1呢?

  之前說過,鏈表的查找需要從第一個元素開始“順藤摸瓜”式的查找,所以我們需要先創建一個指向head的指針ptr,循環i次,每次都把ptr後移,即執行ptr=ptr->next,我們就可以找到元素i-1的位置(仔細想想,紙上畫畫就明白了),然後ptr->next就是元素i的位置。


  然後我們把ptr看做是head,將p插入ptr和元素i之間,與頭部插入類似,先p->next=ptr->next;將p的指針域指向元素i,如下圖所示:


  然後再將ptr的指針域指向p,即ptr->next=p,完成新元素的插入。如下圖:


  至此任意位置插入操作完成,實現代碼如下:

template<class T>
void LinkList<T>::insert(T elem, int index)  // 向index位置插入
{
    if(index > cnt || index < 0) // 非法位置,忽略
    {
        return ;
    }
    if(index == 0) // 頭部插入,直接調用push_front
    {
        push_front(elem);
    }
    else if(index == cnt) // 尾部插入,直接調用push_back
    {
        push_back(elem);
    }
    else
    {
        Node<T> *p = new Node<T>;
        p->data = elem;
        int i = index;
        Node<T> *ptr = head;
        while(i--)
        {
            ptr = ptr->next;
        }
        p->next = ptr->next;
        ptr->next = p;
        cnt++;
    }
}

(4) 刪除任意位置的元素(del)

  由於單鏈表是單向鏈接的線性表 ,也就是說,只能通過前置元素找到後繼元素,反之則不行。所以我們要刪除某個元素,就要事先知曉待刪除元素的前置元素。假設待刪除元素爲元素i,則需要將元素i-1的指針域指向元素i+1,也就是“跳過”元素i,這樣元素i就“脫離”了鏈表的鏈子,然後再釋放它的內存即可。

  首先第一步,與任意位置插入一樣,我們需要一個初始在head的ptr指針,移動i次使它指向元素i的前置結點i-1,如圖所示:


  由於i元素是我們要刪除的元素,如果此時修改ptr的指針域使鏈表“跨過”元素i,那麼元素i沒有指針指向將無法找到,也就不能對其釋放內存,所以我們需要用一個指針指向它,假設爲p,p即待刪元素的指針,即p=ptr->next,然後再修改ptr的指針域爲元素i的下一元素,也就是ptr->next=p->next,修改過後如圖所示:


  做完這一步,觀察上圖,實際上鍊表中已經沒有了i元素,但是因爲是刪除元素,我們需要將i元素從內存中釋放,即釋放p指針所指內容,使用delete p即可。

同樣,刪除也有一種特殊情況,存在尾指針時,如果刪除的元素爲最後一個元素(n-1元素),則需要修改尾指針指向。

  代碼如下:

template<class T>
void LinkList<T>::del(int index) // 刪除index位置的元素
{
    if(index >= cnt || index < 0) // 非法位置,忽略
    {
        return ;
    }
    Node<T> *ptr = head;
    int i = index;
    while(i--)
    {
        ptr = ptr->next;
    }
    Node<T> *p = ptr->next; // 獲取待刪元素指針
    ptr->next = p->next;
    delete p;
    if(index == cnt - 1) // 如果刪除的元素是最後一個
    {
        tail = ptr; // 修改尾指針指向爲ptr(刪除元素的前置)
    }
    cnt--;
}

(5) 獲取任意位置的元素指針(get)

  我們這裏寫的方法是獲取元素指針而不是獲取元素值,目的是方便修改和獲取元素,當然,熟練了以後可以分成兩個方法來寫,畢竟指針操作還是蠻危險的,這裏爲了簡便,就寫成指針了。
  同樣,由於鏈表是單向的,所以我們需要從頭遍歷找到需要的元素。由於起始元素是元素0,我們需要獲取的是元素本身,所以與前面插入刪除不同,我們要初始化一個ptr爲head->next而不是head,循環i次以後,ptr指向的位置就是元素i,然後返回ptr指針即可,比較簡單就不做圖啦~直接上代碼:

template<class T>
Node<T>* LinkList<T>::get(int index) // 獲取index位置的元素
{
    if(index >= cnt) // 如果給定位置過大,則默認返回最後元素
    {
        index = cnt - 1;
    }
    if(index < 0) // 如果給定位置過小,則默認返回首元素
    {
        index = 0;
    }
    int i = index;
    Node<T> *ptr = head->next; // 指針指向首元素
    while(i--)
    {
        ptr = ptr->next;
    }
    return ptr;
}

(6)  獲取鏈表長度(size)

  這個簡單,直接返回私有字段cnt即可,代碼:

template<class T>
int LinkList<T>::size() // 獲取鏈表的大小
{
    return cnt;
}

(7)  遍歷輸出鏈表,並以split參數爲間隔

  這個方法就是做來輸出查看的,事實上對於容器的遍歷我們應該使用迭代器,由於比較複雜且超出數據結構範疇,所以這裏直接輸出。由於泛型的存在,所以這裏的輸出只能輸出基本數據類型,其他類型會出錯。

  原理還是一樣,初始化一個指針p指向鏈表首元素,循環判斷是不是爲空,非空就輸出元素的值,然後p指針後移,即p=p->next,直到p指針爲空,遍歷結束。代碼如下:

template<class T>
void LinkList<T>::each(char split) // 遍歷輸出鏈表,以split參數爲間隔
{
    Node<T> *p = head->next;
    while(p)
    {
        cout<<(p->data);
        p=p->next;
        putchar(p == NULL ? '\n' : split);
    }
}

  好啦,至此,關於鏈表的基本操作就講解並實現完了,鏈表作爲數據結構課程裏第一個要學習的東西,其實實現起來還是蠻複雜的,有好多特殊的地方需要考慮,而且各種指針操作一不小心就炸了 。

  所以學習數據結構不僅要求我們有紮實的編程基礎(特別是指針),還要求我們有足夠的耐心和信心,這只是區區單鏈表,本章接下來幾節還會講解雙向鏈表和循環鏈表,後面的章節還會有更復雜的數據結構與算法。

  下面是整個單鏈表的定義和實現,沒有分寫頭文件和實現文件,main函數裏寫了幾個例子試了一下,如下:

#include<bits/stdc++.h>

using namespace std;

template<class T>
struct Node
{
    T data;
    Node<T> *next;
};

template<class T>
class LinkList
{
private:
    Node<T> *head; // 頭部結點指針
    Node<T> *tail; // 尾部指針
    int cnt; // 鏈表長度
public:
    LinkList()  // 構造函數,初始化鏈表
    {
        head = new Node<T>; // 獲取一個頭結點
        head->next = NULL; // 將頭結點指針域置空
        tail = head; // 尾指針指向頭部,此時鏈表爲空
        cnt = 0;
    }
    void push_back(T elem);  // 向尾部插入(正向建表)
    void push_front(T elem); // 向頭部插入(逆序建表)
    void insert(T elem, int index);  // 向index位置插入
    void del(int index); // 刪除index位置的元素
    Node<T>* get(int index); // 獲取index位置的元素指針
    int size(); // 獲取鏈表的大小
    void each(char split); // 遍歷輸出鏈表,以split參數爲間隔
};
template<class T>
void LinkList<T>::push_back(T elem)  // 向尾部插入(正向建表)
{
    Node<T> *p = new Node<T>;
    p->data = elem;
    p->next = NULL;
    tail->next = p;
    tail = p;
    cnt++;
}
template<class T>
void LinkList<T>::push_front(T elem) // 向頭部插入(逆序建表)
{
    Node<T> *p = new Node<T>;
    p->data = elem;
    p->next = head->next;
    head->next = p;
    if(cnt == 0) // 無元素,移動尾指針
    {
        tail = p;
    }
    cnt++;
}
template<class T>
void LinkList<T>::insert(T elem, int index)  // 向index位置插入
{
    if(index > cnt || index < 0) // 非法位置,忽略
    {
        return ;
    }
    if(index == 0) // 頭部插入,直接調用push_front
    {
        push_front(elem);
    }
    else if(index == cnt) // 尾部插入,直接調用push_back
    {
        push_back(elem);
    }
    else
    {
        Node<T> *p = new Node<T>;
        p->data = elem;
        int i = index;
        Node<T> *ptr = head;
        while(i--)
        {
            ptr = ptr->next;
        }
        p->next = ptr->next;
        ptr->next = p;
        cnt++;
    }
}
template<class T>
void LinkList<T>::del(int index) // 刪除index位置的元素
{
    if(index >= cnt || index < 0) // 非法位置,忽略
    {
        return ;
    }
    Node<T> *ptr = head;
    int i = index;
    while(i--)
    {
        ptr = ptr->next;
    }
    Node<T> *p = ptr->next; // 獲取待刪元素指針
    ptr->next = p->next;
    delete p;
    if(index == cnt - 1) // 如果刪除的元素是最後一個
    {
        tail = ptr; // 修改尾指針指向爲ptr(刪除元素的前置)
    }
    cnt--;
}
template<class T>
Node<T>* LinkList<T>::get(int index) // 獲取index位置的元素
{
    if(index >= cnt) // 如果給定位置過大,則默認返回最後元素
    {
        index = cnt - 1;
    }
    if(index < 0) // 如果給定位置過小,則默認返回首元素
    {
        index = 0;
    }
    int i = index;
    Node<T> *ptr = head->next; // 指針指向首元素
    while(i--)
    {
        ptr = ptr->next;
    }
    return ptr;
}
template<class T>
int LinkList<T>::size() // 獲取鏈表的大小
{
    return cnt;
}
template<class T>
void LinkList<T>::each(char split) // 遍歷輸出鏈表,以split參數爲間隔
{
    Node<T> *p = head->next;
    while(p)
    {
        cout<<(p->data);
        p=p->next;
        putchar(p == NULL ? '\n' : split);
    }
}
int main()
{
    LinkList<int> lst;
    lst.push_back(1);
    lst.each(' ');
    lst.push_back(2);
    lst.push_back(3);
    lst.each(' ');
    lst.del(1);
    lst.each(' ');
    lst.get(0)->data = 666;
    lst.each(' ');

    return 0;
}


  如有不足的地方,歡迎大家指正交流~(手動滑稽

  下集預告&傳送門: 數據結構與算法專題之線性表——鏈表(二)雙向鏈表

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章