數據結構與算法 —— 線性表之單鏈表

本文首發於我的個人博客:https://staunchkai.com

引言

上一篇的順序存儲結構中提到,順序存儲結構最大的缺點是 插入和刪除時需要移動大量的元素, 這就需要耗費大量的時間。針對這個問題,線性表的鏈式存儲就出現了。

「鏈式存儲」 直接不考慮相鄰位置的問題,哪裏有位置就把元素放到哪裏,每個元素多用一個位置來存放 指向下一個元素的指針,這樣就可以從上一個元素找到下一個元素,它們之間的位置是隨機的。

鏈式存儲結構

線性錶鏈式存儲結構是用一組任意的存儲單元存放線性表的元素,這組存儲單元可以存在內存中未被佔用的任意位置。

相比與順序存儲結構,有如下區別:

  • 順序存儲: 只需要存儲一個位置(元素)
  • 鏈式存儲: 除了需要存儲一個位置(元素),還需要存儲它的後繼元素的存儲地址(指針)

我們把存儲數據元素信息的域稱爲數據域(通俗的說,域就是地方),把存儲直接後繼位置的域稱爲指針域。指針域中存儲的信息稱爲指針或鏈。這兩部分信息組成的數據元素稱爲存儲映像,稱爲結點(Node)。

鏈表的每個結點中只包含一個指針域,所以它叫做單鏈表。

鏈式存儲

對於線性表,都有一個頭一個尾,在鏈式存儲中,把鏈表的第一個結點的存儲位置叫做頭指針,最後一個結點指針爲空(NULL),如上圖所示。

鏈式存儲 頭結點 的數據域一般不存儲任何信息,起着帶頭的作用即可,舉個小旗子。

頭指針頭結點 的區別:
頭指針:

  • 頭指針是指向鏈表第一個結點的指針,若鏈表有頭結點,則是指向頭結點的指針
  • 頭指針具有標識的作用,常用頭指針冠以鏈表的名字(指針變量的名字)
  • 無論鏈表是否爲空,頭指針不爲空
  • 頭指針是鏈表的必要元素

頭結點:

  • 頭結點是爲了操作的統一和方便而設定的,放在第一個元素結點之前,其數據域一般無意義(但是也可以用來存放鏈表的長度)
  • 有了頭結點,對在第一結點前插入結點和刪除第一結點的操作,與其他後面的操作就統一了
  • 頭結點不一定是鏈表的必需要素

單鏈表存儲結構

單鏈表如圖所示:
單鏈表

空鏈表如圖所示:
空鏈表

定義結構體

#include <stdio.h>
#include <stdlib.h>

#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0

typedef int ElemType;
typedef int Status;
typedef struct Node
{
    ElemType data;  // 數據域
    struct Node *Next;  // 指針域(指向一個結點類型的指針)
} Node;
typedef struct Node *LinkList;

單鏈表的讀取

在線性表的順序存儲結構中,要獲取某個元素的存儲位置是很容易的,但是在單鏈表中,獲取某一個元素的位置,相對較爲麻煩,因爲我們不知道獲取的元素位置到底在哪,必須從第一個結點開始一個一個的找下去。

獲取鏈表第 i 個數據的算法思路:

  1. 聲明一個結點 p 指向第一個結點(因爲必須從第一個結點開始尋找),初始化 j 從 1 開始;
  2. 通過 j 來遍歷鏈表,直到 j < i時(遍歷到了 i 的時候就找到了),讓 p 的指針不斷向後移動,不斷指向下一個結點, j + 1;
  3. 若到鏈表末尾處,也就是 p 爲空,則說明第 i 個元素不存在;
  4. 若查找成功,返回結點 p 的數據。
/* 通過位置獲取元素 */
Status GetElem(LinkList L, int i, ElemType *e)
{
    LinkList p; // 指針
    int j = 1;  // 用於遍歷

    p = L->Next;    // p 指向鏈表的第一個結點

    while(p && j < i)    // p 不爲空,p 爲空指向的是鏈表尾部,當 j=i 已經找到了
    {
        p = p->Next;    // p 指向下一個結點
        j++;
    }

    if(!p)  // 當上一個循環 p 爲 NULL,也就是 p 爲假後退出,此處 !p 應該爲真
        return ERROR;

    *e = p->data;

    return OK;
}

此算法的時間複雜度取決於 i 的位置,因此最壞情況下的時間複雜度爲 O(n)。並且由於單鏈表結構中沒有定義表長,不知道需要循環多少次,因此採用 while 循環比較合適。

單鏈表的插入

假設要存儲的元素 e 的結點爲 s,實現結點 pp->nexts 之間的邏輯關係如下圖:
鏈表插入

要將結點 s 插入進去,只需要將結點 p 指向 s,結點 s 指向結點 p->next
鏈表插入

單鏈表第 i 個數據插入結點的算法思路:

  1. 聲明一個結點 p 指向鏈表表頭結點,初始化 j 從 0 開始;
  2. j < i 時,遍歷鏈表,讓 p 的指針向後移動,不斷指向下一個結點,j + 1
  3. 若到鏈表末尾,此時 p 爲空,說明第 i 個元素不存在;
  4. 否則查找成功,生成一個空結點 s
  5. 將數據元素 e 賦值給 s->data
  6. 單鏈表插入,返回成功。
/* 插入元素 */
Status InsertElem(LinkList L, int i, ElemType e)
{
    LinkList p;
    int j = 0;
    p = L;

    while(p && j < i - 1)    // 用於尋找第 i 個結點
    {
        p = p->Next;
        j++;
    }

    if(!p)
        return ERROR;

    LinkList s;     //聲明一個空結點
    s = (LinkList)malloc(sizeof(Node));     // 分配地址空間給 s
    s->data = e;    // 將要插入的元素賦值給 s 結點

    s->Next = p->Next;  // s 結點指向 p->next 結點
    p->Next = s;    // p 結點指向 s 結點

    return OK;
}

單鏈表的元素刪除

鏈表刪除

點鏈表刪除第 i 個元素結點的算法思路:

  1. 聲明結點 p 指向鏈表第一個結點,初始化 j = 1
  2. j < i 時,遍歷鏈表,讓 p 的指針向後移動,不斷指向下一個結點,j + 1
  3. 若到鏈表末尾,此時 p 爲空,說明第 i 個元素不存在;
  4. 否則查找成功,將要刪除的結點 p->next 賦值給 q
  5. 將原來的 q->next 賦值給 p->next
  6. q 結點中的數據賦值給 e 作爲返回;
  7. 釋放 q 結點。
/* 刪除元素 */
Status DeleteElem(LinkList *L, int i, ElemType *e)
{
    LinkList p;
    int j = 1;

    p = *L;;

    while(p->Next && j < i)
    {
        p = p->Next;
        j++;
    }

    if(!(p->Next))
        return ERROR;

    LinkList q;
    q = (LinkList)malloc(sizeof(Node));
    q = p->Next;
    p->Next = q->Next;

    *e = q->data;
    free(q);

    return OK;
}

單鏈表的創建

單鏈表的數據不像順序存儲結構那麼集中,它的數據分散在內存的各個角落,它的增長也是動態的。對於鏈表,所佔用空間的大小和位置是不需要事先分配劃定的,可根據情況和實際需求即使生成。

創建單鏈表的過程是一個動態生成鏈表的過程,從「空表」的初始狀態起,依次建立各個元素的結點,並逐個插入鏈表。

單鏈表創建的算法思路如下

  1. 聲明一個結點 p, 一個判斷是否輸入結束的變量 i;
  2. 初始化一個空鏈表 L
  3. L 的頭結點的指針指向 NULL,即建立一個帶頭結點的單鏈表;
  4. 循環實現後繼結點的賦值和插入。

頭插法(頭部插入法)

頭插法從一個空表開始,生成新結點,讀取數據存放到新結點的數據域中,然後將新結點插入到當前鏈表的表頭上,直至結束爲止。簡單來說,就是把新加入的元素放在表頭後的第一個位置上:

  1. 讓新節點的 next 指向頭結點之後的 NULL
  2. 然後讓表頭的 next 指向新結點。

始終讓新結點插在第一的位置

例如:

// 要插入的元素有:
s t a u n c h k a i
// 使用頭插法後爲:
i a k h c n u a t s
/* 頭插法建立單鏈表 */
void CreateListHead(LinkList *L)
{
    ElemType e;
    LinkList p;
    int i = 1;

    *L = (LinkList)malloc(sizeof(Node));    // 申請新結點內存空間
    (*L)->Next = NULL;  // 初始化
    printf("輸入表中的數據元素,輸入 -100 結束!\n");
    while(i)
    {
        scanf("%d", &e);
        if(e == -100)   // 當輸入 -100 時,判斷輸入結束,結束循環
            i = 0;
        else
        {
            p = (LinkList)malloc(sizeof(Node)); // 申請新結點空間
            p->data = e;    // 將輸入的數據存入到申請的結點空間域
            p->Next = (*L)->Next;   // 原來頭結點的直接後繼稱爲新結點的直接後繼
            (*L)->Next = p; // 新結點變爲頭結點的直接後繼
        }
    }
}

尾插法(尾部插入法)

上面的頭插法生成鏈表後,輸入的數據和結果的數據順序相反,這時我們就可以換一個角度,把新結點都插入到最後,這種算法稱爲尾插法。

/* 尾插法創建單鏈表 */
void CreateListTail(LinkList *L)
{
    ElemType e;
    LinkList p, r;
    int i = 1;

    *L = (LinkList)malloc(sizeof(Node));
    r = *L;     // 使用 r 指向鏈表的尾部 NULL
    printf("輸入表中的數據元素,輸入 -100 結束!\n");
    while(i)
    {
        scanf("%d", &e);
        if(e == -100)   // 當輸入 -100 時,判斷輸入結束,結束循環
            i = 0;
        else
        {
            p = (LinkList)malloc(sizeof(Node)); // 申請新結點空間
            p->data = e;    // 將輸入的數據存入到申請的結點空間域
            r->Next = p;
            r = p;  // r=p 後 r->next 相當於再次指向了 NULL
        }
        r->Next = NULL;
    }
}

點鏈表的整表刪除

當不打算再使用這個點鏈表時,就需要把整個表給銷燬了,也就是在內存中將其釋放,以便於節省空間,在上面的刪除元素,我們釋放的只是單個結點的內存空間。

單鏈表整表刪除的算法思路如下:

  1. 聲明結點 pq
  2. 將第一個結點賦值給 p,下一個節點賦值給 q
  3. 循環執行釋放 p,釋放後將 q 賦值給 p 的操作。
/* 刪除整表 */
Status ClearList(LinkList *L)
{
    LinkList p, q;

    p = (*L)->Next; // p 指向頭結點

    while(p)    // p 有數據就爲真
    {
        q = p->Next;// q 指向 p 的下一個結點
        free(p);    // 釋放 p
        p = q;      // 釋放後的 p 爲空,將 q(也就是之前的 p->next) 賦值給 p
    }
    (*L)->Next = NULL;  // 最後爲空表

    return OK;
}

單鏈表打印

/* 打印表 */
void PrintList(LinkList L)
{
    LinkList p;
    p = L->Next;
    printf("L->");

    while(p)
    {
        printf(" %d->", p->data);
        p = p->Next;
    }
    printf("NULL\n");
}

測試代碼

int main()
{
    /* 選擇子函數 */
    int select()
    {
        int s;
        printf("輸入要操作的序號:\n------------------------------\n");
        printf("1. 單鏈表建立(頭插法)\n");
        printf("2. 單鏈表建立(尾插法)\n");
        printf("3. 單鏈表結點插入\n");
        printf("4. 單鏈表結點刪除\n");
        printf("5. 單鏈表打印\n");
        printf("6. 單鏈表整表刪除\n");
        printf("0. 退出\n------------------------------\n");

        for(;;)
        {
            scanf("%d", &s);
            if(s < 0 || s > 6)
                printf("輸入錯誤,重新輸入!");
            else
                break;
        }
        return s;
    }

    LinkList L;
    ElemType e;
    int i;

    for(;;)
    {
        switch(select())
        {
        case 1:
            printf("單鏈表建立(頭插法)\n");
            CreateListHead(&L);
            break;
        case 2:
            printf("單鏈表建立(尾插法)\n");
            CreateListTail(&L);
            break;
        case 3:
            printf("單鏈表結點插入\n");
            printf("輸入要插入的位置:");
            scanf("%d", &i);
            printf("輸入要插入的元素:");
            scanf("%d", &e);
            if(InsertElem(L, i, e) == OK)
                printf("插入成功!\n");;
            break;
        case 4:
            printf("單鏈表結點刪除\n");
            printf("輸入要刪除元素的位置:");
            scanf("%d", &i);
            if(DeleteElem(&L, i, &e) == OK)
                printf("\n刪除的元素爲 %d\n", e);
            break;
        case 5:
            printf("單鏈表打印如下:\n");
            PrintList(L);
            break;
        case 6:
            printf("單鏈表整表刪除\n");
            if(ClearList(&L) == OK)
                printf("刪除成功!\n");
            break;
        case 0:
            printf("再見!\n");
            return 0;
        }
    }
    return 0;
}

單鏈表結構與順序存儲結構的優缺點

存儲分配方式

  • 順序存儲結構用一段連續的存儲單元依次存儲線性表的數據元素
  • 單鏈表採用鏈式存儲結構,用一組任意的存儲單元存放線性表的元素

時間性能

1. 查找

  • 順序存儲結構 O(1)
  • 單鏈表 O(n)

2. 插入和刪除

  • 順序存儲結構需要平均移動表長一半的元素,時間爲 O(n)
  • 單鏈表在計算出某位置的指針後,插入和刪除的時間僅爲 O(1)

3. 空間性能

  • 順序存儲結構需要事先分配存儲空間,分大了,容易造成空間浪費,分小了,容易發生溢出
  • 單鏈表不需要分配存儲空間,進行動態分配,有一個分配一個,元素的個數也不受限制

綜上所述,得出結論:

  • 若線性表需要頻繁進行查找,很少進行插入和刪除的操作,適合採用順序存儲結構
  • 若需要頻繁插入和刪除,適合採用單鏈表結構

例如:

  • 在遊戲開發中,對於用戶註冊的個人信息,除了註冊時插入數據外,絕大多數情況都是讀取,所以應考慮採用順序存儲結構
  • 而遊戲中武器或者裝備列表,隨着玩家在遊戲過程中的增加或刪除,此時採用單鏈表結構較爲適合
  • 當線性表中的元素個數變化較大或者根本不知道有多大時,最好採用單鏈表結構,這樣不需要考慮存儲空間大小的問題
  • 如果事先知道線性表的大致長度,例如:一年的 12 個月,一週的七天等,這種採用順序存儲結構效率會高很多。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章