本文首發於我的個人博客: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 個數據的算法思路:
- 聲明一個結點
p
指向第一個結點(因爲必須從第一個結點開始尋找),初始化j
從 1 開始; - 通過
j
來遍歷鏈表,直到j < i
時(遍歷到了i
的時候就找到了),讓p
的指針不斷向後移動,不斷指向下一個結點,j + 1
; - 若到鏈表末尾處,也就是
p
爲空,則說明第i
個元素不存在; - 若查找成功,返回結點
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
,實現結點 p
、p->next
和 s
之間的邏輯關係如下圖:
要將結點 s
插入進去,只需要將結點 p
指向 s
,結點 s
指向結點 p->next
。
單鏈表第 i 個數據插入結點的算法思路:
- 聲明一個結點
p
指向鏈表表頭結點,初始化j
從 0 開始; - 當
j < i
時,遍歷鏈表,讓p
的指針向後移動,不斷指向下一個結點,j + 1
; - 若到鏈表末尾,此時
p
爲空,說明第 i 個元素不存在; - 否則查找成功,生成一個空結點
s
; - 將數據元素
e
賦值給s->data
; - 單鏈表插入,返回成功。
/* 插入元素 */
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 個元素結點的算法思路:
- 聲明結點
p
指向鏈表第一個結點,初始化j = 1
; - 當
j < i
時,遍歷鏈表,讓p
的指針向後移動,不斷指向下一個結點,j + 1
; - 若到鏈表末尾,此時
p
爲空,說明第 i 個元素不存在; - 否則查找成功,將要刪除的結點
p->next
賦值給q
; - 將原來的
q->next
賦值給p->next
; - 將
q
結點中的數據賦值給 e 作爲返回; - 釋放
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;
}
單鏈表的創建
單鏈表的數據不像順序存儲結構那麼集中,它的數據分散在內存的各個角落,它的增長也是動態的。對於鏈表,所佔用空間的大小和位置是不需要事先分配劃定的,可根據情況和實際需求即使生成。
創建單鏈表的過程是一個動態生成鏈表的過程,從「空表」的初始狀態起,依次建立各個元素的結點,並逐個插入鏈表。
單鏈表創建的算法思路如下:
- 聲明一個結點
p
, 一個判斷是否輸入結束的變量 i; - 初始化一個空鏈表
L
; - 讓
L
的頭結點的指針指向NULL
,即建立一個帶頭結點的單鏈表; - 循環實現後繼結點的賦值和插入。
頭插法(頭部插入法)
頭插法從一個空表開始,生成新結點,讀取數據存放到新結點的數據域中,然後將新結點插入到當前鏈表的表頭上,直至結束爲止。簡單來說,就是把新加入的元素放在表頭後的第一個位置上:
- 讓新節點的
next
指向頭結點之後的NULL
; - 然後讓表頭的
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;
}
}
點鏈表的整表刪除
當不打算再使用這個點鏈表時,就需要把整個表給銷燬了,也就是在內存中將其釋放,以便於節省空間,在上面的刪除元素,我們釋放的只是單個結點的內存空間。
單鏈表整表刪除的算法思路如下:
- 聲明結點
p
和q
; - 將第一個結點賦值給
p
,下一個節點賦值給q
; - 循環執行釋放
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 個月,一週的七天等,這種採用順序存儲結構效率會高很多。