動態單鏈表的傳統存儲方式和10種常見操作-C語言實現


順序線性表的優點:方便存取(隨機的),特點是物理位置和邏輯為主都是連續的(相鄰)。但是也有不足,比如;前面的插入和刪除算法,需要移動大量元素,浪費時間,那么鏈式線性表 (簡稱鏈表) 就能解決這個問題。

 

一般鏈表的存儲方法

一組物理位置任意的存儲單元來存放線性表的數據元素,當然物理位置可以連續,也可以不連續,或者離散的分配到內存中的任意位置上都是可以的。故鏈表的邏輯順序和物理順序不一定一樣。

 

因為,鏈表的邏輯關系和物理關系沒有必然聯系,那么表示數據元素之間的邏輯映象就要使用指針,每一個存儲數據元素的邏輯單元叫結點(node)。

結點里有兩個部分,前面存儲數據元素內容,叫數據域,后面部分存儲結點的直接后繼在內存的物理位置,叫指針域。那么就可以實現用指針(也叫鏈)把若干個結點的存儲映象鏈接為表,就是鏈式線性表。

 

上面開始介紹的是最簡單的鏈表,因為結點里只有一個指針域,也就是俗稱的單鏈表(也叫線性鏈表)。

 

單鏈表可以由一個叫頭指針的東東唯一確定,這個指針指向了鏈表(也就是直接指向第一個結點)。因此單鏈表可以用頭指針的名字來命名。且最后一個結點指針指向NULL。

 

鏈表類別

1、實現的角度:動態鏈表,靜態鏈表(類似順序表的動態和靜態存儲)

2、鏈接方式的角度:單鏈表,雙向鏈表,循環鏈表

 

單鏈表

 

單鏈表的頭結點

一般是使用單鏈表的頭指針指向鏈表的第一個結點,有的人還這樣做,在第一個結點之前再加一個結點(不保存任何數據信息,只保存第一個結點的地址,有時也保存一些表的附加信息,如表長等),叫頭結點(頭結點是頭結點,第一個結點是第一個結點)。那么此時,頭指針指向了頭結點。並且有無頭結點都是可以的。鏈表還是那個鏈表,只不過表達有差異。

 

那么問題來了!為什么還要使用頭結點?

作用是對鏈表進行操作時,可以對空表、非空表的情況以及對首元結點進行統一處理,編程更方便。

 

描述動態單鏈表

有兩種做法,一種是傳統的動態鏈表結構,暨只定義結點的存儲結構,使用4個字節的指針變量表示線性表。還有一種是直接采用結構體變量(類似順序表)來表示線性表。

顧名思義,肯定是第二種方法比較方便。


先看傳統存儲動態單鏈表結構的操作

 1 /************************************************************************/
 2 /* 文件名稱:ADT.h
 3 /* 文件功能:動態單鏈表傳統存儲結構和常見操作
 4 /* 作    者:dashuai
 5 /* 備    注:以下算法,並沒有考證是否全部都是最佳優化的,關於算法的優化后續研究
 6 /************************************************************************/
 7 #include <stdio.h>
 8 #include <stdlib.h>
 9 
10 //傳統的單鏈表動態存儲結構(帶頭指針)
11 typedef struct Node//Node標記可以省略,但如結構里聲明了指向結構的指針,那么不能省略!
12 {
13     char data;//數據域
14     struct Node *next;//指針域
15 } Node, *LinkList;//LinkList是指向Node結構類型的指
16 
17 /*
18     1、查找(按值)算法
19     找到第一個值等於value的結點,找到則返回存儲位置
20 */
21 LinkList getElemByValue(LinkList L, char value);
22 
23 /*
24     2、查找(按序號)算法
25     查找第i個元素,並用變量value返回值,否則返回提示,即使知道了序號,也必須使用指針順次查訪
26 */
27 void getElemByNum(LinkList L, int num, char *value);
28 
29 /*
30     3、刪除結點
31     刪除元素,並用value返回值
32 */
33 void deleteList(LinkList L, int i, char *value);
34 
35 /*
36     4、插入結點
37     在第i個位置之前插入結點e
38 */
39 void insertList(LinkList L, int i, char value);
40 
41 /*
42     5、頭插法建立單鏈表(逆序建表)
43     從一個空結點開始,逐個把 n 個結點插入到當前鏈表的表頭
44 */
45 void createListByHead(LinkList *L, int n);
46 
47 /*
48     6、尾插法建立單鏈表(順序建表)
49     從一個空結點開始,逐個把 n 個結點插入到當前鏈表的表尾
50 */
51 void createListByTail(LinkList *L, int n);
52 
53 /*
54     7、尾插法建立有序單鏈表(歸並鏈表)
55     把兩個有序(非遞減)鏈表LA LB合並為一個新的有序(非遞減)鏈表LC(空表)
56     要求:不需要額外申請結點空間來完成
57 */
58 void mergeListsByTail(LinkList LA, LinkList LB, LinkList LC);
59 
60 /*
61     8、銷毀單鏈表
62 */
63 void destoryLinkList(LinkList *L);
64 
65 /*
66     9、求表長,長度保存到頭結點
67 */
68 void getLength(LinkList L);
69 
70 /*
71     10、遍歷單鏈表
72 */
73 void traversalList(LinkList L);
View Code

C變量的隨用隨定義,可以確定C99之后新增加的,和c++一樣,貌似一些編譯器還不支持

 1 #include "ADT.h"
 2 
 3 /*
 4     1、查找(按值)算法
 5     找到第一個值等於value的結點,找到則返回存儲位置
 6 */
 7 LinkList getElemByValue(LinkList L, char value)
 8 {
 9     //定義指示指針指向L第一個結點
10     LinkList p = L->next;//L開始指向了頭結點,L->next指向的就是第一個結點
11 
12     while (p && p->data != value)
13     {
14         //p++;顯然錯誤,離散存儲,非隨機結構
15         p = p->next;//指針順鏈移動,直到找到或者結束循環
16     }
17     //當找不到,則p最終指向NULL,循環結束
18     return p;//返回存儲位置或NULL
19 }

算法執行時間和value有關,時間復雜度為0(n),n表長

 1 /*
 2     2、查找(按序號)算法
 3     查找第i個元素,並用變量value返回值,否則返回提示,即使知道了序號,也必須使用指針順次查訪
 4 */
 5 void getElemByNum(LinkList L, int num, char *value)
 6 {
 7     int count = 1;
 8     LinkList p = L->next;
 9     //控制指針p指向第num個結點
10     while (p && count < num)//num若為0或者負數直接跳出循環,若超出表長則遍歷完畢,跳出循環,找到了元素也跳出循環
11     {
12         p = p->next;//p指向第num個結點,count此時為num值
13         count++;
14     }
15     //如果num大於表長,則count值增加到表長度時,p恰好指向表尾結點,遍歷完整個鏈表也找不到結點,此時再循環一次
16     //p指向null,count = 表長 + 1,循環結束,這里也隱含說明了num大於 表長 的不合法情況
17     if (!p || count > num)//說明num至少比 表長 大
18     {
19         //num <= 0或者num大於表長時,跳出while循環,來到if語句,判斷不合法
20         puts("num值非法!");
21     }
22     else
23     {
24         *value = p->data;
25         printf("找到第%d個元素的值 = %c\n", num, *value);
26     }
27 }

//時間復雜度0(n),n為表長

 1 /*
 2     3、刪除結點
 3     刪除第i個元素,並用value返回值
 4 */
 5 
 6 void deleteList(LinkList L, int i, char *value)
 7 {
 8     LinkList p = L;//頭腦一定要清晰!這里p應該指向頭結點
 9     LinkList temp;
10     int j = 0;//對應着頭結點的序號0
11 
12     /*while (p && j < i)
13     {
14         p = p->next;此時,p指向的是i元素位置,要刪除i元素,需要知道i的前驅!
15         j++;
16     }*/
17         
18     while (p->next && j < i - 1)//i - 1可以保證指向其前驅 ,j=0需要注意,刪除是從1-n都可以
19     {
20         p = p->next;//指向i元素前驅(i-1)
21         j++;
22     }
23     //必須想到判斷合法性
24     if (!(p->next) || j > i - 1)//同樣判斷i的下邊界,和上邊界
25     {
26         puts("i值不合法!");
27     }
28     else
29     {
30         //找到了前驅p
31         temp = p->next;//temp指針指向i元素
32         //p->next = p->next->next;等價於
33         p->next = temp->next;//鏈表刪除結點的關鍵邏輯
34         *value = temp->data;
35         free(temp);//釋放temp指向的結點的內存空間
36         temp = NULL;
37         puts("刪除成功!");
38     }
39 }

時間復雜度,雖然沒有移動任何元素,還是0(n),因為最壞時核心語句頻度為n(表長)

為什么不是p?

//判斷表為非空,因為不止一次刪除操作!總會刪空,則p還是指向的頭結點!如果依然是while(p &&……),表空時,按道理函數不應該再執行核心語句,提前判斷出錯,但此時卻還要執行循環體,循環結束才能到if(!p),而使用while(p->next && ……),表空就直接跳出循環,到if語句,提示錯誤。這是刪除算法總是需要注意的細節,插入算法則是如果內存有限,或者是順序的表,或者靜態鏈表,那么總是要注意存儲空間滿足大小的問題

 

 1 /*
 2     4、插入結點
 3     在第i個位置之前插入結點e,換句話說就是在第i-1個位置之后插入,類似前面的刪除操作思想
 4 */
 5 void insertList(LinkList L, int i, char value)
 6 {
 7     LinkList p = L;
 8     LinkList s;
 9     int j = 0;
10     //和刪除不同,插入操作不用注意表空的情況
11     while (p && j < i - 1)
12     {
13         p = p->next;
14         j++;
15     }
16 
17     if (!p || j > i - 1)//i小於1或者大於表長+1不合法
18     {
19         puts("i不合法!");
20     }
21     else
22     {
23         //先分配結點存儲空間
24         s = (LinkList)malloc(sizeof(Node));
25         //依次傳入插入的元素內容和修改邏輯鏈接
26         s->data = value;
27         //鏈表插入算法的關鍵邏輯
28         s->next = p->next;
29         p->next = s;//順序不可顛倒,原則是指針在修改前,先保留再修改,不能先修改再保留
30         puts("插入成功!");
31     }
32 }

插入算法時間復雜度分析:0(n),最壞情況下頻度是n

/插入和刪除算法,還有一個思路,就是既然需要每次都找前驅,那么為什么不弄兩個指針呢?一個指向當前位置,一個緊隨其后指向前,個人其實感覺是脫褲子放屁……

 

 1 //以刪除為例
 2 void deleteList(LinkList L, int i, char *value)
 3 {
 4     LinkList prePoint = L;//前驅指針初始化指向頭結點
 5     LinkList point = L->next;//當前指針初始化指向第一個結點
 6     LinkList temp = NULL;
 7     int j = 1;
 8     //i要>0,且小於等於表長
 9     while (point && j < i)//如果表非空,找到要刪除的元素位置
10     {
11         point = point->next;
12         prePoint = prePoint->next;//分別順次后移
13         j++;
14     }
15 
16     if (!point || j > i)
17     {
18         puts("i不合法!");
19     }
20     else
21     {
22         temp = point;
23         prePoint->next = point->next;
24         *value = temp->data;
25         free(temp);
26         temp = NULL;
27         puts("刪除成功!");
28     }
29 }

時間復雜度依然是O(n)

 

 1 /*
 2     5、頭插法建立單鏈表(逆序建表)
 3     從一個空結點開始,逐個把 n 個結點插入到當前鏈表的表頭
 4 */
 5 
 6 //開始就空表,則肯定先分配結點(帶頭結點)
 7 void createListByHead(LinkList *L, int n)
 8 {
 9     LinkList p = NULL;
10     int i = 0;
11     *L = (LinkList)malloc(sizeof(Node));//L指向頭結點
12     (*L)->next = NULL;//空表建立
13     //頭插法
14     for (i = 1; i <= n; i++)
15     {
16         p = (LinkList)malloc(sizeof(Node));//先創建要插入的結點
17         scanf("%c", &(p->data));//給結點數據域賦值    再次驗證 -> 優先級高於取地址 &
18 
19         while (getchar() != '\n')
20         {
21             continue;
22         }
23 
24         p->next = (*L)->next;//再讓插入的結點的next指針指向后繼(鏈接的過程),注意后繼不能為空(除去第一次插入)
25         //p->next = NULL;//錯誤
26         (*L)->next = p;//最后保證插入結點是第一個結點,把頭結點和第一個結點鏈接起來。
27     }
28 
29     printf("頭插法建表成功\n");
30 }

時間復雜度:必然是O(n),插入了n個元素

鏈表和順序表存儲結構不同,動態,整個可用內存空間可以被多個鏈表共享,每個鏈表無需事先分配存儲容量,由系統應要求自動申請。建立鏈表是動態的過程。

//如a b c d,依次頭插法(頭插 總是在第一個結點前插入,暨插入的結點總是作為第一個結點)到空鏈表里,那么完成之后是

//d c b a

 

下面是正序的尾插法,如圖

 1 /*
 2     6、尾插法建立單鏈表(順序建表)
 3     //對 5 算法的改進
 4 */
 5 //頭插法算法簡單,但生成的鏈表結點次序和輸入的順序相反。有時不太方便。
 6 //若希望二者次序一致,可采用尾插法建表。該方法是將新結點順次的插入到當前鏈表的表尾上,為此必須增加一個尾指針tail,
 7 //使其始終指向當前鏈表的尾結點。
 8 void createListByTail(LinkList *L, int n)
 9 {
10     LinkList tail = NULL;//尾指針
11     int i = 0;
12     LinkList p = NULL;//代表前驅
13     *L = (LinkList)malloc(sizeof(Node));
14     (*L)->next = NULL;
15     tail = *L;
16 
17     for (i = 1; i <= n; i++)
18     {
19         p = (LinkList)malloc(sizeof(Node));
20         //_CRTIMP int __cdecl scanf(_In_z_ _Scanf_format_string_ const char * _Format, ...);返回int,傳入的參數個數
21         /*如果被成功讀入,返回值為參數個數
22         如果都未被成功讀入,返回值為0
23             如果遇到錯誤或遇到end of file,返回值為EOF*/
24         scanf("%c", &(p->data));
25         //清空輸入隊列的剩余的所有字符
26         while (getchar() != '\n')
27         {
28             continue;
29         }
30         //尾插操作,后繼總是空的
31         p->next = NULL;
32         //鏈接前驅   
33         tail->next = p;//萬萬不能寫成*L->next = p;
34         //保證尾指針tail總是指向最后一個結點
35         tail = p;
36     }
37 
38     printf("尾插法建表成功! \n");
39 }

這樣操作,輸入的序列和輸出的序列是正序的,且時間復雜度為O(n)

 

 1 /*
 2     7、尾插法建立有序單鏈表(歸並鏈表)
 3     把兩個有序(非遞減)鏈表LA LB合並為一個新的有序(非遞減)鏈表LC(空表)
 4     要求:不需要額外申請結點空間來完成
 5 */
 6 
 7 //1、比較數據域,保證有序
 8 //2、尾插法思想
 9 //3、不需要額外申請結點空間來完成!
10 //使用現有的內存空間,完成操作,那么可以想到用LC的頭指針去指向其中某個表的頭結點,內存共享
11 
12 void mergeListsByTail(LinkList LA, LinkList LB, LinkList LC)
13 {
14     //因為要比較數據大小,需要兩個標記指針,分別初始化為標記AB的第一個結點
15     LinkList listA = LA->next;
16     LinkList listB = LB->next;
17     //還要不開辟內存,那么內存共享,需要把表C讓其他表去表示
18     LinkList listC;//聲明一個標記C的指針
19     LC = LA;//比如表A。C表的頭指針指向A表的頭結點,做自己的頭結點
20     listC = LA;//C表的標記指針需要初始化,指向A的頭結點,待命
21     //接下來比較AB表數據,標記指針會順次后移,早晚有一個先指向末尾之后的NULL,故判斷是哪一個表的
22     while (listA && listB)
23     {
24         //判斷數據大小,非遞減
25         if (listA->data <= listB->data)
26         {
27             //則A的結點插入到C表(尾插),單鏈表不可使用頭指針做遍歷
28             listC->next = listA;//先把A的結點鏈到C表
29             listC = listA;//listC等價於尾指針
30             //A指針后移,繼續循環比較
31             listA = listA->next;
32         }
33         else
34         {
35             //把B的結點插入到C(尾插)
36             listC->next = listB;
37             listC = listB;
38             listB = listB->next;
39         }//end of if
40     }//end of while
41     //循環結束,只需要把剩下的某個表的結點一次性鏈接到C表尾
42     if (listA)
43     {
44         //說明B空
45         listC->next = listA;
46     }
47 
48     if (listB)
49     {
50         //A空
51         listC->next = listB;
52     }
53     //最后AB表比較之前一定有一個表都被遍歷了(也就是鏈接到了C),剩下的結點比如屬於某個表的,最后也都鏈接到C尾部
54     //那么,此時就還有一個結點,那就是B表的頭結點!勿忘把B表頭結點釋放,這才是完全的兩個歸並為一個
55     free(LB);
56     LB = NULL;//杜絕野指針
57 }

算法時間復雜度,和頭插法比較的話,還是O(n),其實順序表的有序歸並也是這個時間復雜度O(A.length + B.length),但是鏈表的尾插法歸並沒有移動元素,只是解除和重建鏈接的操作,也沒有額外開辟內存空間。空間復雜度不同。

 

 1 /*
 2     8、銷毀單鏈表
 3 */
 4 void destoryLinkList(LinkList *L)
 5 {
 6     LinkList p = NULL;
 7 
 8     while (*L)
 9     {
10         p = (*L)->next;
11         free(*L);//free不能對指向NULL的指針使用多次!
12         *L = p;
13         //徹底搞掉指針本身,free(L)僅僅是銷毀指針指向的內存,故還要連頭結點一起干掉,不過while循環里隱形的包含了
14     }
15 
16     *L = NULL;
17     puts("鏈表L已經銷毀,不存在!");
18 }

函數內部有動態分配內存的情形,應該把參數設定為指向指針的指針,當然還有別的方法,我習慣而已。

記得說:值傳遞函數,是把實參的一個拷貝送到函數體,函數體修改的是那份傳入的拷貝,不是函數跑到main里去給它修改。

且形參和傳入的拷貝,還有函數體內的變量(棧中分配的內存),都是是代碼塊內的自動存儲類型的變量,也就是局部變量,函數執行完畢,變量自動銷毀,改變就不起作用。

指針形參可以改變實參,但是如果是針對函數內動態分配了內存的情況,把堆分配的內存地址賦給了指針參數,改變的是指針指向的內容,而指針變量(形參)本身的內存地址沒有改變,故根本句不會成功修改實參。

指向指針的指針,存放的是指向實參內存地址A的指針的地址B,修改B地址,改變了B指向的內容,而B指向的內容恰恰就是一級指針A本身,一級指針A的修改,使得實參被改變,對實參(指針變量C),需要取出指針C自己的的地址,傳入函數。達到間接修改實參的目的。

 

 1 /*
 2     9、求表長,長度保存在頭結點
 3 */
 4 void getLength(LinkList L)
 5 {
 6     int length = 0;
 7     LinkList p = NULL;
 8 
 9     if (L)
10     {
11         p = L->next;
12 
13         while (p)
14         {
15             p = p->next;
16             length++;
17         }
18 
19         L->data = length;
20         printf("%d\n", L->data);
21     }
22     else
23     {
24         puts("表已經銷毀!無法計算長度了……");
25     }
26 }

 

 1 /*
 2     10、遍歷鏈表    
 3 */
 4 void traversalList(LinkList L)
 5 {
 6     int i = 0;
 7     int length = 0;
 8     LinkList p = L->next;
 9     length = L->data;//遍歷之前,務必先求表長!
10     puts("遍歷之前,務必先求表長!");
11     
12     for (; i < length; i++)
13     {
14         putchar(p->data);
15         p = p->next;
16     }
17     putchar('\n');
18 }

 

測試

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 #include <assert.h>
 4 #include "ADT.h"
 5 
 6 int main(void)
 7 {
 8     LinkList L = NULL;//完全的空表,連頭結點都沒有
 9     LinkList LTwo = NULL;
10     LinkList val = NULL;
11     int i = 0;
12     char value = '0';
13 
14     puts("請輸入5個字符變量(一行一個):");
15     puts("使用尾插法建立單鏈表L,5個結點,一個頭結點");
16     //輸入 12345
17     createListByTail(&L, 5);//尾插法建表
18 
19     //12345正確的存入到了5個結點里,尾插法創建了一個單鏈表L
20     
21     //先求長度
22     puts("L長度為");
23     getLength(L);
24 
25     //遍歷  12345
26     puts("遍歷這個鏈表結點元素");
27     traversalList(L);
28 
29     //用完必須銷毀
30     puts("把鏈表L銷毀,L = NULL;");
31     destoryLinkList(&L);
32 
33     //頭插法 abcde  
34     //void createListByHead(&LTwo, 5);//報錯;語法錯誤 : 缺少“;”(在“類型”的前面)
35     puts("使用頭插法建立新的單鏈表LTwo,5個結點,一個頭結點");
36     createListByHead(&LTwo, 5);
37 
38     //求長度
39     getLength(LTwo);
40 
41     //遍歷  edcba
42     puts("遍歷表中結點元素");
43     traversalList(LTwo);
44 
45     //按值查找
46     puts("查找LTwo表的結點數據 = ‘2’的結點");
47     val = getElemByValue(LTwo, '2');
48 
49     if (val)
50     {
51         printf("找到了val,地址 = %p \n", &val);
52     }
53 
54     puts("‘2’在表 LTwo 里沒找到!");
55 
56     //插入結點
57     puts("在位置 1 之后插入一個結點,里面數據是 ‘p’");
58     insertList(LTwo, 1, 'p');
59 
60     //遍歷  pedcba
61     puts("開始遍歷表LTwo");
62     traversalList(LTwo);
63 
64     //按序查找
65     puts("查找位置=2的結點,並打印出它的數據內容");
66     getElemByNum(LTwo, 2, &value);
67     
68 
69     //刪除結點
70     puts("刪除位置 1 的結點,並打印出刪除結點的數據");
71     deleteList(LTwo, 1, &value);
72     printf("%c\n", value);
73 
74     //遍歷  pedcba
75     puts("再次遍歷鏈表LTwo");
76     traversalList(LTwo);
77 
78     //求鏈表長度,把長度保存的頭結點
79     puts("計算鏈表長度,並把長度保存到了LTwo的頭結點");
80     getLength(LTwo);
81     printf("%d\n", LTwo->data);
82 
83     //必須有銷毀
84     puts("動態存儲的結構用完一定要銷毀");
85     destoryLinkList(&LTwo);
86 
87     //此時銷毀的表長規定是0
88     puts("銷毀之后,鏈表長度:");
89     getLength(LTwo);
90 
91     system("pause"); 
92     return 0;
93 }

scanf函數的特點是接受單詞,而不是字符串,字符串一般是gets函數,單個字符接收是getchar函數,因為scanf函數遇到空白字符(tab,空格,回車,制表符等)就不再讀取輸入,那字符串怎么能方便輸入?

但是輸入隊列里如果還有字符,那么會留到緩存內,需要在定義里使用getchar函數來消除回車帶來的影響。

 

歡迎關注

dashuai的博客是終身學習踐行者,大廠程序員,且專注於工作經驗、學習筆記的分享和日常吐槽,包括但不限於互聯網行業,附帶分享一些PDF電子書,資料,幫忙內推,歡迎拍磚!

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM