1. 程式人生 > >Single linked List by pointer

Single linked List by pointer

amp head 對比 問題: 第一個 ins empty n) style

其實本應該從一般性的表講起的,先說順序表,再說鏈表 。但順序表的應用範圍不是很廣,而且說白了就是數組的高級版本,他的優勢僅在於兩點:1.邏輯直觀,易於理解。2.查找某個元素只需要常數時間——O(1),而與此同時,因為每個單元的物理內存都是連續的,所以不便於移動,不便於精細化操作,每次插入和刪除都會帶來巨額的時間開銷。什麽叫巨額時間開銷 舉個栗子:我要在開頭加一個數進去,那我要把所有的元素都往後移一位,空出來一個位置,這就需要穿過整個表,假如這個表有1000萬個元素,大家可以自己腦補一下要花多久,答案是O(n)。這是時間的浪費。而如果用來計算稀疏多項式,比如:x^100+x^1300+x,這會造成中間有很多存儲單元裏存的是0,沒有任何意義,但卻實實在在消耗了內存,這是空間浪費。

因為插入和刪除的運行時間非常慢,而且表的大小還必須事先已知,所以一般不用簡單數組來實現表這種結構。

技術分享

現在我們需要一種靈活的方法來使我們突破連續儲存帶來的限制,那怎麽辦呢?就不連續儲存唄,把空間離散化。每一個單元裏面大體分為兩部分,一邊存數據,另一邊存下一個單元的地址,這樣一來,邏輯上仍然是連續的,而在物理內存中則是星羅棋布了。這就是我們要學的鏈表了。這是我們要學的第一種線性結構——啥意思,就是一串單元。

現在,我們要加入或者移除一個元素的時候,就不必擔心會對全體數據造成影響了,只需要改動“鏈條”就好了,其他不變。這樣就能減少增刪的時間開銷了。單鏈表的樣子就像這樣:

技術分享

可以腦補一列火車車廂hhhhhh 當然火車車廂是雙鏈表,我們現在簡便起見,先介紹單鏈表。

刪除的命令可以通過修改一個指針來實現,就像這樣:

技術分享

插入的話,我們需要申請一個新單元,怎麽申請?printf("請給我一點內存,謝謝");

顯然不是的,對吧。那該怎麽做?

技術分享

對!malloc函數,說到這個,我多說幾句啊。首先,別拼錯了,我之前會手滑打錯,也遇到過記不住這個函數名字的小白,咱得記住它的意思“Memory Allocate“,內存分配,這個內存從哪分配的呢,總不會是操作系統憑空變出來的,它是從“堆(Heap)”上分配來的,這個堆也是我們以後要學的一種數據結構。

接著說插入,申請一個新單元之後,我們再做兩次指針調整就好了,就像這樣:

技術分享

重點:整個鏈表的核心在於指針的調整,而首先,我們要“拉住”整個鏈表,也就是說我們需要一個引子,來牽住整個一長串的表,這個引子就相當於火車頭。因為每一個單元的物理位置都是隨機的,想找到下一個只能依靠前一個單元的尾針(畢竟這是單鏈表)。

技術分享操作指針時一定要小心,包括調整順序和指向。否則就會像這樣

技術分享

我們的目標是成為老司機,不要翻車。

好了,大概的思路我們已經捋順了,現在來說具體怎麽做。

前面提到了,我們需要一個引子,具體做法是留出一個標誌節點,習慣稱為表頭(header)。說是節點,其實僅僅是一個指針,沒有存放數據的位置。像這樣

這裏註意一點:表頭後的一個單元,叫做頭節點,這個節點有數據域,但是也不存有效數據,它僅僅是證明表的存在性。(我初學的時候因為這點沒搞透徹,導致代碼運行時各種bug,大家要引以為戒啊……)

接下來是代碼實現,首先約定一些名字

 1 struct Node;                //先聲明一個節點,後面定義
 2 typedef struct Node *PtrToNode;     /*聲明節點指針,並且將其類型替換為PtrToNode(替換之前是struct Node*),這樣做的目的是方便我們理解*/
 3  
 4  
 5 typedef PtrToNode List; //將struct Node*再次替換為List(表),進一步直觀化
 6 typedef PtrToNode Position;//將struct Node*再次替換為Position(位置)
 7  
 8 struct Node{
 9     int data;
10     Position Next;
11 };

這裏的List和Position有什麽區別呢?可能很多人會有這個疑問,區別在於,List指表頭,Position指某個單元,在後面代碼中我們會有更清晰的認識,走吧,咱們繼續。

現在我們來一一討論針對鏈表的各個操作函數。這裏多說一句,關於函數返回值的問題:因為C語言沒有bool類型,也就是TRUE和FALSE,所以它用1表示真,0表示假。

(你們不要嫌我啰嗦……)

首先,判斷某個表是否為空

1 /*如果某個表為空,返回1*/
2 
3 int IsEmpty(List L){
4 
5     return L->Next==NULL;//在最後一個單元裏,後面是封口的,也就是指針域是NULL
6 
7 }

我們還需要判斷某個表是不是在末尾,這是為了作為循環的終止條件。寫法上和判空沒什麽區別,只是分開寫會更方便理解,在完整的代碼裏我們會感受到的,拭目以待吧。

1  
2 /*如果P是在表中的末尾位置,返回1*/
3 int IsLast(Position P) {
4     return P->Next==NULL;
5 }
6  

這些小零碎寫完之後,我們就需要把大的零件寫出來了(看吧,在咱們這個領域,要搞一件工程,無論說創造零件還是拼裝零件,只需要智力加持,代碼就會從手中滑落而出,是不是很優雅2333)

對於大部分線性結構,我們要做的操作大體上分為:增加,刪除,查找,遍歷這四種,我們先寫查找,因為這是刪除的基礎。為什麽刪除之前要先查找呢?兩個原因,1.我們一般是告訴系統要刪除的某個“數據”,也就是節點裏data的值,所以要先找到這個值所在的節點是哪個,2.因為這是單鏈表,如果不拿到要刪除元素的前驅,我們可能會丟失整個表。

刪除有一個很重要的步驟是釋放內存,用free函數,我們剛開始學可能會想當然,覺得直接找到那個元素,然後free一下就好了,那就會造成後面的表全部丟失,這是災難性的後果。

來寫一個查找前驅的函數,它返回一個前驅,假如我們給一個3,他就返回3前面那個表的位置,上面說的原因2決定了寫這個函數的必要性,而原因1決定了這個函數還需要一個int型參數。

 1 Position FindPrevious(int X,List L){
 2 
 3     Position P;                 //聲明一個節點指針,並指向頭節點(和表頭一樣)
 4 
 5          P=L;
 6 
 7     while (P!=NULL && P->Next->data!=X) { //P沒有走到末尾,同時還沒找到給定的X時
 8 
 9         P=P->Next;                  //P向後走
10 
11       }           //走到這一步時,說明要麽沒找到,P=NULL(結尾處),要麽找到了,P=前驅的位置
12 
13     return P;
14 
15 }

第二行用到“與(&&)”操作走了捷徑,也就是說,如果“與”運算前半部分為假,結果就自動為假,後半部分不再執行,也就是短路操作,咱們上學期講過。

按邏輯來說,應該緊接著寫刪除函數的,正好和FindPrevious相配。但是我想強調一個重要的點,一會再說刪除,先說查找函數,這個和上面的區別是:這個返回”當前位置“,上面那個返回”前一個位置“。

 1 Position Find(int X,List L) {
 2 
 3     Position P;
 4 
 5     P=L->Next;      //和上面對比一下,區別在哪?
 6 
 7     while (P!=NULL && X!=P->data) {
 8 
 9         P=P->Next;
10 
11     }
12 
13     return P;
14 
15 }

這個大體思路和上面一樣,但有一個要點,這個函數的起始位置在第一個有效元素,比查找前驅的函數靠後一位,原因好理解吧,這個要查找當前位置,而不是前一個,所以從L->Next開始。

好了,我們該說刪除操作了

 1 void Delete(int X,List L) {
 2 
 3     Position P,Temp;        //申請兩個節點,一個用作拉住前驅,一個用作臨時變量
 4 
 5     P=FindPrevious(X, L);   //用P拉住X的前驅
 6 
 7     if (!IsLast(P)) {       //確定P不是末尾,否則沒法刪除(末尾後面什麽也沒有)
 8 
 9     Temp=P->Next;       //用臨時指針拉住當前位置,以便後面直接越過這個節點
10 
11     P->Next=Temp->Next; //當前節點的前驅直接指向後繼,繞過了當前節點
12 
13     free(Temp);         //釋放當前節點內存
14 
15     Temp->Next=NULL;    //將當前節點的指針“收回來”,腦補一下飛機起落架。
16 
17     }
18 
19 }

這裏面有兩個我想說的地方

  • 9,11行用了一個臨時指針,是為了怕大家繞暈,其實也可以寫成P->Next=P->Next->Next; 不過這樣一來就不好理解了,肯定一堆人默默吐槽兩個Next是什麽鬼啊。
  • 第15行貌似教材裏沒有,但這是為了防止出現野指針。

下面我們說插入函數,分為從前插入和從後插入,emmmm好汙的感覺(捂臉),,向前插入的一個特點是,表頭位置不變,而向後插入需要不斷更新Position。

先說從前插入,分為三步:

      1. 打開冰箱
      2. 把大象放進去
      3. 關上冰箱

(劃掉)

其實是:

      1. 分配內存
      2. 向後鏈接
      3. 向前鏈接

 1 void InsertBefore(int X,Position P){
 2 
 3     Position NewNode;            //用一個臨時變量,用以“拴住”新單元
 4 
 5     NewNode=(List)malloc(sizeof(struct Node));  //申請內存,List相當於struct Node*
 6 
 7     NewNode->data=X;              //將數據填入新單元
 8 
 9     NewNode->Next=P->Next;        //與後方單元相連
10 
11     P->Next=NewNode;              //與前方單元相連,這兩行順序不能反,原因…你們試試就知道了
12 
13 }

是這個樣子

技術分享

這種情況數組的元素是逆序的。

再說向後插入

 1 void PushBack(int X,Position P) {
 2 
 3     Position NewNode;       //聲明一個新的節點指針
 4 
 5     NewNode=(Position)malloc(sizeof(struct Node));//分配內存
 6 
 7     NewNode->Next=NULL;     //新節點指針域封閉
 8 
 9     NewNode->data=X;        //數據裝填
10 
11     while(!IsLast(P))       //通過循環走到整個鏈表的末尾
12 
13         P=P->Next;
14 
15     P->Next=NewNode;        //將新節點的地址交給原鏈表末尾,從尾部鏈接。
16 
17 }

這個更容易理解。

再寫一個遍歷函數,用於打印所有的元素

 1 void Traverse(List L){
 2 
 3     while (L->Next!=NULL) {
 4 
 5         printf("%d ",L->Next->data);
 6 
 7         L=L->Next;
 8 
 9     }
10 
11     printf("\n");

下面是用於演示的主程序,根據自己的需要隨意往裏面增減部件吧

 1 int main(){
 2 
 3     int i;
 4 
 5     List L=(List)malloc(sizeof(struct Node));
 6 
 7     L->Next=NULL;
 8 
 9     printf("Input amount of lists\n");
10 
11     scanf("%d",&i);
12 
13     
14 
15     while (i--) {
16 
17         int n;
18 
19         scanf("%d",&n);
20 
21         PushBack(n, L);
22 
23     }
24 
25     Traverse(L);
26 
27     printf("Which item would you like to remove?\n");
28 
29     scanf("%d",&i);
30 
31     Delete(i, L);
32 
33     printf("\n");
34 
35     printf("The current linked lists are : ");
36 
37     Traverse(L);
38 
39 }

其實每個程序員都是魔法師,程序和算法就是現代的魔法,努力修煉自己的法術吧。

下一篇寫遊標實現。

p.s.為了方便大家理解,我會在每一行代碼後面寫註釋

pp.s 數據結構是不依賴於具體實現的,所以教科書裏用一般性的ElemType表示數據類型,我這裏簡單起見,全部用int表示

Single linked List by pointer