詳解|寫完這篇文章我終於搞懂鏈表了

一覽:本文從零介紹鏈式存儲結構的線性表——單鏈表。包括如下內容:node

  • 什麼是鏈式存儲存儲結構?
  • 單鏈表的結構
  • 辨析頭結點、頭指針等易混淆概念
  • 基本的增刪改查操做(不帶頭結點和帶頭結點)
  • 單鏈表與順序表的對比

線性表的鏈式存儲結構

【順序表詳解】一文中咱們介紹了一種「用曲線鏈接」的線性表,「曲線」是一種形象化的語言,實際上並不會存在所謂「曲線」的這種東西。git

曲線鏈接元素

所謂「曲線鏈接」即鏈式存儲,那什麼是鏈式存儲呢?github

【順序表詳解】一文中介紹順序存儲結構時舉了一個例子:算法

好比,一羣孩子肩並肩地站成一排,佔據必定的連續土地。這羣孩子的隊伍很是整齊劃一。數組

這個例子反映在內存中,即爲順序存儲結構。網絡

順序存儲類比圖

如今孩子們以爲肩並肩站隊太過於約束,因而便散開了,想站在哪裏就站在哪裏,可是隊伍不能中斷啊。因此他們便手拉着手來保持隊伍不斷開。數據結構

如今孩子們佔據的不是連續的土地了,而是任意的某塊土地。數據結構和算法

這個例子反映在內存中,就是數據元素任意出現,佔據某塊內存。函數

鏈式存儲類比圖

上面兩張圖放在一塊對比,就能夠看出問題了:學習

  • 直線 VS 曲線
  • 整齊劃一 VS 雜亂無章
  • 連續內存空間 VS 任意內存空間
  • 順序存儲 VS 鏈式存儲

【順序表詳解】一文中提到過,線性表的特色之一是元素之間有順序。順序存儲結構靠連續的內存空間來實現這種「順序」,但鏈式存儲結構的元素是「任意的」,如何實現「順序」呢?

「任意」和「順序」乍一看很像反義詞,但並不矛盾。小孩子們爲了避免讓隊伍中斷便「手拉手」,咱們要實現「順序」,就靠那根「像鏈條同樣的曲線」來把元素連接起來,這樣就有了順序。

這就是鏈式存儲。

【順序表詳解】一文中提到過「順序存儲結構是使用一段連續的內存單元分別存儲線性表中的數據的數據結構」。咱們照貓畫虎,就能夠獲得「鏈式存儲結構是使用一組任意的、不連續的內存單元來分別存儲線性表中的數據的數據結構」的結論。

那我偏就使用連續的內存單元來存數據,可不能夠?固然能夠。

下圖直觀地畫出了線性表的元素以鏈式存儲的方式存儲在內存中。

鏈式存儲在物理內存上的關係

爲了方便畫圖,咱們將表示順序的曲線人爲地拉直,將任意存儲的元素人爲地排列整齊,造成下圖中的邏輯關係:

鏈式存儲在邏輯上關係

這種鏈式存儲結構的線性表,簡稱爲鏈表

上圖的鏈表之間用一個單向箭頭相連,從一個指向另外一個,這樣整個鏈表就有了一個方向,咱們稱之爲單鏈表

總結一下特色:

  1. 用一組任意的存儲單元來存儲線性表的數據元素,這組存儲單元能夠是連續的(1和3),也能夠是不連續的;
  2. 這組任意的存儲單元用「一根鏈子」串起來,因此雖然在內存上是亂序的,可是在邏輯上卻仍然是線性的;
  3. 單鏈表的方向是單向的,只能從前日後,不能從後往前;

單鏈表的實現思路

由於單鏈表是任意的內存位置,因此數組確定不能用了。

由於要存儲一個值,因此直接用變量就行。

由於單鏈表在物理內存上是任意存儲的,因此表示順序的箭頭(小孩子的手臂)必需要用代碼實現出來。

而箭頭的含義就是,經過 1 能找到 2。

又由於咱們使用變量來存儲值,因此,換句話說,咱們要經過一個變量找到另外一個變量。

變量找變量?怎麼作到?

C 語言恰好有這個機制——指針。若是你對指針還不清楚,能夠移步到文章【如何掌握 C 語言的一大利器——指針?】

如今萬事俱備,只差結合。

一個變量用來存值,一個指針用來存其直接後繼的地址,把變量和指針綁在一塊構成一個數據元素,啪的一下,就成了。

鏈表的結點

因爲指針中存了其直接後繼的地址,這就至關於用一個有向箭頭指向了其直接後繼。

先明確幾個名詞:

  • 用來存數據的變量叫作數據域
  • 用來存「直接後繼元素的地址」的 指針 叫作指針域
  • 數據域和指針域構成的數據元素叫作 結點 (node)

總結一下,一個單鏈表 (SinglyLinkedList) 的結點 (Node) 由如下幾部分組成:

  • 用來存數據的數據域——data
  • 用來存直接後繼結點的的地址的指針域——next

結點的具體實現

可使用 C 語言的結構體來實現結點:

爲了說明問題簡單,咱們這裏的結點只存儲整數。

//結點
typedef struct Node {
    int data; //數據域:存儲數據
    struct Node *next; //指針域:存儲直接後繼結點的地址
} Node;

這樣的一個結構體就能完美地表示一個結點了,許多結點連在一塊就能構成鏈表了。

一個結點

單鏈表的結構

單鏈表由若干的結點單向連接組成,一個單鏈表必需要有頭有尾,由於計算機很「笨」,不像人看一眼就知道頭在哪尾在哪。因此咱們要用代碼清晰地表示出一個單鏈表的全部結構。

頭指針

請先注意 「頭」 這個概念,咱們在平常生活中拿繩子的時候,總喜歡找「繩子頭」,此「頭」即彼「頭」。

而後再理解 「指針」 這個概念(如不清楚指針,請移步至請移步至文章【如何掌握 C 語言的一大利器——指針?】),指針裏面存儲的是地址。

那麼,頭指針即存儲鏈表的第一個結點的內存地址的指針,也即頭指針指向鏈表的第一個結點。

下圖是一個由三個結點構成的單鏈表:

(爲了方便理解鏈表的結構,我會給出每一個結點的地址以及指針域的值)

頭指針

指針 head 中保存了第一個結點 1 的地址,head 即爲頭指針。有了頭指針,咱們就能夠找到第一個結點的位置,就能夠找到整個鏈表。一般,頭指針的名字就是鏈表的名字。

即,head(頭指針)在手,鏈表我有

值爲 3 的結點是該鏈表的「尾」,因此它的指針域中保存的值爲 NULL用來表示整個鏈表到此爲止

咱們用頭指針表示鏈表的開始,用 NULL 表示鏈表的結束

頭結點

在上面的鏈表中,咱們能夠在值爲 1 的結點前再加一個結點,稱其爲頭結點。見下圖:

頭結點

頭結點的數據域中通常不存放數據(能夠存放如結點數等可有可無的數據),從這點看,頭結點是不一樣於其餘結點的「假結點」。

此時頭指針指向頭結點,由於如今頭結點纔是第一個結點

爲何要設立頭結點呢?這能夠方便咱們對鏈表的操做,後面你將會體會到這一點。

固然,頭結點不是鏈表的必要結構之一,他無關緊要,僅憑你的喜愛

有無頭結點的單鏈表

既然頭結點不是鏈表的必要結構,這就意味着能夠有兩種鏈表:

  • 帶頭結點的單鏈表
  • 不帶頭結點的單鏈表

再加上頭指針,初學鏈表時,「咱們的頭」很容易被「鏈表的頭」搞暈。

別暈,看下面兩幅圖:

不帶頭結點的單鏈表

帶頭結點的單鏈表

記住如下幾條:

  • 頭指針很重要,沒它不行
  • 雖然頭結點能夠方便咱們的一些操做,可是有沒有都行
  • 不管帶頭結點與否,頭指針都指向鏈表的第一個結點

(後面的操做咱們將討論不帶頭結點和帶頭結點兩種狀況)

空鏈表

不帶頭結點

下圖是一個不帶頭結點的空鏈表:

即頭指針中存儲的地址爲 NULL

帶頭結點

下圖是一個帶頭結點的空鏈表:

此時頭指針中存儲的是第一個結點——頭結點的地址,頭結點的指針域中存儲的是 NULL

(後面的圖示將再也不給出內存地址)

創造結點

至此,關於單鏈表的基本概念、實現思路、結點的具體實現咱們都已經瞭解了,但這些還都停留咱們的腦子裏。下面要作的就是把咱們腦子裏的東西,之內存喜聞樂見的形式搬到內存中去。

由於鏈表是由結點組成的,因此咱們先來創造結點。

/**
 * 創造結點,返回指向該結點的指針
 * elem : 結點的數據域的值
 * return : 指向該結點的指針(該結點的地址)
 */
Node *create_node(int elem)
{
    Node *node = (Node *) malloc(sizeof(Node));
    node->data = elem;
    node->next = NULL;
    return node;
}

注意:咱們要使用 malloc 函數給結點申請一塊內存,而後才能對該結點的數據域進行賦值,而因爲該結點此時是一個獨立的結點,沒有直接後繼結點,因此其指針域爲 NULL

初始化鏈表

初始化鏈表即初始化一個空鏈表,詳見本文【空鏈表】一節中的兩幅圖。

不帶頭結點

要初始化一個不帶頭節點的鏈表,咱們直接建立一個能夠指向結點的空指針便可,該空指針即爲頭指針

Node *head = NULL;

帶頭結點

帶頭結點的單鏈表的特色是多了一個不存儲數據的頭結點,因此咱們初始化鏈表時,要將其建立出來。

可是在建立以前,咱們先來搞清楚三個問題,分別是:

  1. 鏈表的頭指針

  2. 指向【指向結點的指針】的指針

  3. 函數參數的值傳遞和地址傳遞

簡單解釋:

  1. 頭指針是鏈表必定要有的,找到頭指針才能找到整個鏈表,不然整個鏈表就消失在「茫茫內存」之中了。因此不管進行何種操做,頭指針必定要像咱們攥緊繩子頭同樣「被攥在咱們手中」。
  2. 指針中保存了別人的地址,它也有本身地址。若是一個指針中保存了別的指針的地址,該指針就是「指向指針的指針」。由於頭指針是指向鏈表第一個結點的指針,因此咱們找到頭指針也就找到了整個鏈表(這句話囉嗦太多遍了)。而爲了能找到頭指針,咱們就須要知道頭指針的地址,也即將頭指針的地址保存下來,換句話說,用一個指針來指向頭指針
  3. 函數的值傳遞改變的是形參(實參的一份拷貝),影響不了實參。因此在一些狀況下,咱們須要傳給函數的是地址,函數使用指針來直接操做該指針指向的內存。

若是以上內容還不清楚,說明對指針的掌握還不夠熟練,請移步至文章【如何掌握 C 語言的一大利器——指針?】

下面畫一張比較形象的圖:

上圖中頭指針和鏈表像不像一根帶手柄的鞭子?

好比下面這個我小時候常常玩的遊戲

打陀螺遊戲 圖源來自網絡

/**
 * 初始化鏈表
 * p_head: 指向頭指針的指針
 */
void init(Node **p_head)
{
    //建立頭結點
    Node *node = (Node *) malloc(sizeof(Node));
    node->next = NULL;
    //頭指針指向頭結點
    *p_head = node;
}

遍歷操做

所謂遍歷,就是從鏈表頭開始,向鏈表尾一個一個結點進行遍歷,咱們一般藉助一個輔助指針來進行遍歷。

不帶頭結點

不帶頭結點的鏈表從頭指針開始遍歷,因此輔助指針的初始位置就是頭指針,這裏咱們以獲取鏈表的長度爲例:

int get_length(Node *head)
{
    int length = 0;
    Node *p = head;
    while (p != NULL) {
        length++;
        p = p->next;
    }
    return length;
}

使用 for 循環能使代碼看起來更精簡些。

帶頭結點

帶頭結點的鏈表須要從頭結點開始遍歷,因此輔助指針的初始位置是頭結點的後繼結點:

int get_length(Node *head)
{
    int length = 0;
    Node *p = head->next;
    while (p != NULL) {
        length++;
        p = p->next;
    }
    return length;
}

插入操做

基本思想

咱們在前面舉了「小孩子手拉手」這個例子來描述單鏈表。

小孩子手拉手

孩子 A 和 B 手拉手連接在一塊兒,如今有個孩子 C 想要插到他們之間,怎麼作?

C 拉上 B 的手 => A 鬆開 B 的手(虛線表示鬆開) => A 拉上 C 的手

A 鬆開 B 的手 => A 拉上 C 的手 => C 拉上 B 的手

一樣地,在鏈表中,咱們也是相似的操做:

寫成代碼就是:

new->next = current;
previous->next = new;

或這換一下順序:

previous->next = new;
new->next = current;

這兩句就是插入操做的核心代碼,也是各類狀況下插入操做的不變之處,搞明白這兩句,就能夠以不變應萬變了。

其中 previouscurrentnew 是三個指向結點的指針, new 指向要插入的結點, previouscurrent 一前一後,在進行插入操做以前的關係爲:

current = previous->next;

事實上, current 指針不是必需的,只有一個 previous 也能夠作到插入操做,緣由就是 current = previous->next,這種狀況下的核心代碼變爲:

new->next = previous->next;
previous->next = new;

但請注意,在這種狀況下兩句代碼是有順序的,你不能寫成:

// 錯誤代碼
previous->next = new;
new->next = previous->next;

咱們能夠從兩個角度來理解爲何這兩句會有順序:

【角度一】

由於 current 指針的做用就是用來保存 previous 的直接後繼結點的地址的,因此在咱們斷開 previouscurrent 聯繫後,咱們仍能找到 current 及其之後的結點。「鏈子」就算暫時斷開了,因爲斷開處兩側都有標記,咱們也能接上去。。

可是如今沒了 current 以後,一旦斷開, current 及其之後的結點就消失在茫茫內存中,這就關鍵時刻掉鏈子了。

因此咱們要先把 newprevious->nextprevious 的直接後繼結點)連起來,這樣一來,指針 new 就保存了它所指向的及其之後的結點,

【角度二】

直接看代碼,previous->next = new 執行完後 new->next = previous->next 就至關於 new->next = new ,本身指本身,這顯然不正確。

總之,把核心代碼理解到位,剩下的就在於如何準確的找到 previouscurrent 的位置。

指定位置插入

不帶頭結點

咱們須要考慮兩種狀況:

  1. 在第一個元素前插入
  2. 在其餘元素前插入
/**
 * 指定插入位置
 * p_head: 指向頭指針的指針
 * position: 指定位置 (1 <= position <= length + 1)
 * elem: 新結點的數據
 */ 
void insert(Node **p_head, int position, int elem)
{
    Node *new = create_node(elem);
    Node *current = *p_head;
    Node *previous = NULL;
    int length = get_length(*p_head);
    if (position < 1 || position > length + 1) {
        printf("插入位置不合法\n");
        return;
    }
    for (int i = 0; current != NULL && i < position - 1; i++) {
        previous = current;
        current = current->next;
    }
    new->next = current;
    if (previous == NULL)
        *p_head = new;
    else
        previous->next = new;
}

帶頭結點

因爲帶了一個頭結點,因此在第一個元素前插入和在其餘元素前插入時的操做是相同的。

/**
 * 指定插入位置
 * p_head: 指向頭指針的指針
 * position: 指定位置 (1 <= position <= length + 1)
 * elem: 新結點的數據
 */
void insert(Node **p_head, int position, int elem)
{
    Node *new = create_node(elem);
    Node *previous = *p_head;
    Node *current = previous->next;
    int length = get_length(*p_head);
    if (position < 1 || position > length + 1) {
        printf("插入位置不合法\n");
        return;
    }
    for (int i = 0; current != NULL && i < position - 1; i++) {
        previous = current;
        current = current->next;
    }
    new->next = current;
    previous->next = new;
}

頭插法

不帶頭結點

不帶頭結點的頭插法,即新插入的節點始終被頭指針所指向。

/**
 * 頭插法:新插入的節點始終被頭指針所指向
 * p_head: 指向頭指針的指針
 * elem: 新結點的數據
 */
void insert_at_head(Node **p_head, int elem)
{
    Node *new = create_node(elem);
    new->next = *p_head;
    *p_head = new;
}

帶頭結點

帶頭結點的頭插法,即新插入的結點始終爲頭結點的直接後繼。

/**
 * 頭插法,新結點爲頭結點的直接後繼
 * p_head: 指向頭指針的指針
 * elem: 新結點的數據
 */
void insert_at_head(Node **p_head, int elem)
{
    Node *new = create_node(elem);
    new->next = (*p_head)->next;
    (*p_head)->next = new;
}

注意:多了一個頭結點,因此代碼有所變化。

尾插法

尾插法要求咱們先找到鏈表的最後一個結點,因此重點在於如何遍歷到最後一個結點。

不帶頭結點

/**
 * 尾插法:新插入的結點始終在鏈表尾
 * p_head: 指向頭指針的指針
 * elem: 新結點的數據
 */
void insert_at_tail(Node **p_head, int elem)
{
    Node *new = create_node(elem);
    Node *p = *p_head;
    while (p->next != NULL)   //從頭遍歷至鏈表尾
        p = p->next;
    p->next = new;
}

帶頭結點

/**
 * 尾插法:新插入的結點始終在鏈表尾
 * p_head: 指向頭指針的指針
 * elem: 新結點的數據
 */
void insert_at_tail(Node **p_head, int elem)
{
    Node *new = create_node(elem);
    Node *p = (*p_head)->next;
    while (p->next != NULL)
        p = p->next;
    p->next = new;
}

刪除操做

基本思想

刪除操做是將要刪除的結點從鏈表中剔除,和插入操做相似。

previouscurrent 爲指向結點的指針,如今咱們要刪除結點 current,過程以下:

核心代碼爲:

previous->next = current->next;
free(current);

free() 操做將要刪除的結點給釋放掉。

current 指針不是必需的,沒有它也能夠,代碼寫成這樣:

previous->next = previous->next->next;

但此時咱們已經不能釋放要刪除的那個結點了,由於咱們沒有一個指向它的指針,它已經消失在茫茫內存中了。

指定位置刪除

知道了核心代碼,剩下的工做就在於咱們如何可以正確地遍歷到要刪除的那個結點

如你所見,previous 指針是必需的,且必定是要刪除的那個結點的直接前驅,因此要將 previous 指針遍歷至其直接前驅結點。

不帶頭結點

/**
 * 刪除指定位置的結點
 * p_head: 指向頭指針的指針
 * position: 指定位置 (1 <= position <= length + 1)
 * elem: 使用該指針指向的變量接收刪除的值
 */
void delete(Node **p_head, int position, int *elem)
{
    Node *previous = NULL;
    Node *current = *p_head;
    int length = get_length(*p_head);
    if (length == 0) {
        printf("空鏈表\n");
        return;
    }
    if (position < 1 || position > length) {
        printf("刪除位置不合法\n");
        return;
    }
    for (int i = 0; current->next != NULL && i < position - 1; i++) {
        previous = current;
        current = current->next;
    }
    *elem = current->data;
    if (previous == NULL)
        *p_head = (*p_head)->next;
    else
        previous->next = current->next;
    free(current);
}

帶頭結點

/**
 * 刪除指定位置的結點
 * p_head: 指向頭指針的指針
 * position: 指定位置 (1 <= position <= length + 1)
 * elem: 使用該指針指向的變量接收刪除的值
 */
void delete(Node **p_head, int position, int *elem)
{
    Node *previous = *p_head;
    Node *current = previous->next;
    int length = get_length(*p_head);
    if (length == 0) {
        printf("空鏈表\n");
        return;
    }
    if (position < 1 || position > length) {
        printf("刪除位置不合法\n");
        return;
    }
    for (int i = 0; current->next != NULL && i < position - 1; i++) {
        previous = current;
        current = current->next;
    }
    *elem = current->data;
    previous->next = current->next;
    free(current);
}

經過 insertdelete函數,咱們就能體會到不帶頭結點和帶頭結點的差異了,對於插入和刪除操做,不帶頭結點須要額外考慮在第一個元素前插入和刪除第一個元素的特殊狀況,而帶頭結點的鏈表則將對全部元素的操做統一了。

還有特殊的刪頭法和刪尾法,這裏再也不給出代碼了

查找和修改操做

查找本質就是遍歷鏈表,使用一個輔助指針,將該指針正確的遍歷到指定位置,就能夠獲取該結點了。

修改則是在查找到目標結點的基礎上修改其值。

代碼很簡單,這裏再也不列出。詳細代碼文末獲取。

單鏈表的優缺點

經過以上代碼,能夠體會到:

優勢:

  • 插入和刪除某個元素時,沒必要像順序表那樣移動大量元素。
  • 鏈表的長度不像順序表那樣是固定的,須要的時候就建立,不須要了就刪除,極其方便。

缺點:

  • 單鏈表的查找和修改須要遍歷鏈表,若是要查找的元素恰好是鏈表的最後一個,則須要遍歷整個單鏈表,不像順序表那樣能夠直接存取。

若是插入和刪除操做頻繁,就選擇單鏈表;若是查找和修改操做頻繁,就選擇順序表;若是元素個數變化大、難以估計,則可使用單鏈表;若是元素個數變化不大、能夠預估,則可使用順序表。

總之,單鏈表和線性表各有其優缺點,不必踩一捧一。根據實際狀況靈活地選擇數據結構,從而更優地解決問題纔是咱們學習數據結構和算法的最終目的

【推薦閱讀】

完整代碼請移步至 GitHub | Gitee 獲取。

若有錯誤,還請指正。

若是以爲寫的不錯能夠關注一下我。

相關文章
相關標籤/搜索