【資料結構基礎筆記】第二章線性表之單鏈表
目錄
一、簡要
好久沒有寫基礎筆記了,當時為了寫基礎筆記是為了能重新鞏固資料結構,後來因為考研,就改成了資料結構週週練,但是很多同學看完我的線性結構順序表之後,希望我能繼續更新,所以,從今天開始,繼續準備資料結構基礎筆記,希望大家喜歡。
第二章一共四小節,第三節講的是連結串列的相關概念及實現,連結串列是線性表的鏈式儲存結構,是後續鏈式儲存的基礎。連結串列,大家可以想成一條鏈子,鏈子擺放在地上的方式各種各樣,但是相鄰兩個鏈子是緊緊聯絡在一起的,普通的單鏈表和鏈子的區別在於,普通鏈子可以通過任意一節到達所有節,而單鏈表只能從第一節開始,到達任意一節,並且這個過程不可逆。
在本節程式碼中,我會加上我大量的個人程式碼理解,包括我思考的一些問題和我自己得到的答案(問題加粗並設為綠色),還有我自己對程式碼邏輯的理解,如果有哪裡寫的不是很完善,或者有一些錯誤的地方,還希望大家能多多提出寶貴意見,本人在此表示感謝。
1、涵蓋內容
1、連結串列的定義、基本操作及特點。
2、單鏈表的實現(包括連結串列的建立、插入和刪除、檢索等)及應用(驗證實現演算法的正確性)。
2、學習要求
1、掌握連結串列的相關概念;
2、能用C語言編寫單鏈表的相關操作,包括建立,查詢,插入,刪除,修改等。
3、掌握順序表和連結串列的差別和各自的優缺點。
二、匯入
我們知道,順序結構有如下特點:邏輯關係上相鄰的兩個元素,在物理位置上也相鄰;這個特點的優點在於可以實現隨機存取
根據簡要我們可以知道,連結串列和順序表相反,它不要求邏輯上相鄰的元素在物理位置上也相鄰,那連結串列有哪些優點,哪些缺點呢?接下來讓我們走進連結串列,一起來看看連結串列的世界。
三、線性連結串列
1、鏈式儲存結構
鏈式儲存結構即用一組任意的儲存單元儲存線性表的資料元素(這組儲存單元可以連續,也可以不連續)。由於沒有物理上的相鄰,想要表達每個資料元素及其後繼元素的關係,不得不損失一個空間來儲存指向其後繼的指標。即對於資料元素ai來說,除了儲存其本身的資訊之外,還需要儲存一個指示其直接後繼的資訊(即直接後繼的儲存位置)。
這兩部分資訊組成資料元素ai的儲存映像,稱為結點(node)
由於每個結點只包含一個指標域,故又稱線性連結串列或單鏈表。
2、注意點
1、整個連結串列的存取必須從頭指標開始,頭指標指示連結串列的第一個結點的儲存位置。
2、最後一個結點沒有直接後繼,所以最後一個結點指標為空。
四、單鏈表
在上面講鏈式儲存結構時,介紹了單鏈表,在此不再說單鏈表的定義。
1、單鏈表優點
1.解決了順序表需要大量移動儲存空間的缺點。
2.不需要實體地址連續的空間,對於部分比較零碎的空間可以得到利用,空間利用率較高。
2、單鏈表缺點
1.單鏈表附加指標域,浪費儲存空間;
2.單鏈表的元素離散在分佈在儲存空間中,是非隨機儲存的儲存結構,查詢需要從表頭開始。
3、結點型別描述
單鏈表需要一個數據域,存放資料;一個指標域,存放其直接後繼結點的地址。
typedef struct LNode {
ElemType data; //資料域
struct LNode *next; //指標域
}LNode, *LinkList;
其中ElemType是使用者自定義資料型別,即使用者線上性表中資料域存放資料元素的型別,為了方便程式碼除錯和後續的程式碼有可重用性,暫時取其為int型。
typedef int ElemType;
4、注意點
通常用“頭指標”來標識一個單鏈表,頭指標為“NULL”時稱之為空表。為了操作上的方便,在單鏈表第一個結點之前附加一個結點,稱為頭結點,頭結點的資料域可以不儲存任何資訊(也可以記錄表長等相關資訊),其指標域指向線性表的第一個元素結點。
頭指標和頭結點有什麼區別嗎?還是有什麼聯絡?
我的理解是:頭指標始終指向連結串列的第一個結點,不管帶不帶頭結點,一個單鏈表可以由其頭指標唯一確定,一般用其頭指標來命名單鏈表。
五、單鏈表的實現
這裡的實現應用的是C語言,因為嚴蔚敏老師這本教材指定的是C語言版。
在所有操作之前,我們需要考慮到需要先定義一些常量,方便在程式碼中使用,常用的常量巨集定義如下:
#define TRUE 1
#define FALSE 0
#define OK 1
#define ERROR 0
#define OVERFLOW -1
同時,為了後續操作方便,我們將相同型別的返回值重新賦給名稱:
typedef int ElemType;
typedef int Status;
1、連結串列的創立
根據我們對前面所有概念的理解,我們知道,順序表需要有一個基地址,來儲存這個陣列,即順序表,同時,順序表要有兩個長度,一個長度是這個順序表當前的儲存容量是多少,另一個長度表示當前順序表的資料有多少個。
typedef struct LNode {
ElemType data; //資料域
struct LNode *next; //指標域
}LNode, *LinkList;
在這裡,我想過一個問題,在說問題之前,先給大家對比一下線性表和連結串列的定義:
/*********順序表*********/
typedef struct {
ElemType *elem;//儲存空間基址,用指標表示
int length;//當前長度
int listsize;//當前分配的儲存量,(以sizeof(ElemType)為單位)
}SqList;
/*********連結串列*********/
typedef struct LNode {
ElemType data; //資料域
struct LNode *next; //指標域
}LNode, *LinkList;
大家有沒有注意到在建立結構時:順序表不用在 struct 後面寫上SqList,但是連結串列在 struct 後面寫上 LNode。LNode可以不寫嗎?
我們把LNode註釋掉,看看會不會報錯:
我的個人理解是:我們在結構體內部應用LNode時是希望它指向下一個結構體結點,要在使用前定義好,如果未定義,它不知道這個LNode和大括號後面的LNode有什麼關係,就會報錯,如果我們提前定義好,就知道這兩個LNode是同一個,就不會重定義,也不會和LinkList出現衝突。
2、連結串列的操作
1.構造一個空的連結串列
用連結串列構造一個空的線性表,首先要分配連結串列的頭結點,然後判斷是否分配成功,如果成功,讓L指向一個空指標,表示建立了一個空的線性連結串列。即如下三個步驟:
1.分配初始空間
2.判斷分配狀況
3.置空(指向空指標)
具體程式碼實現如下:
Status InitList(LinkList &L) {
L = (LinkList)malloc(sizeof(LNode));//產生頭結點,並使L指向此頭結點
if (!L)//儲存分配失敗
exit(OVERFLOW);
L->next = NULL;
return OK;
}
2.銷燬線性表L
首先理解一下漢語,所謂銷燬,就是不留痕跡,什麼都不剩下,銷燬連結串列,就是要把連結串列所有資料全部銷燬,不僅要把L的所有資料銷燬,還有把頭結點銷燬。
因為連結串列的特點是:任意的儲存單元,所以只能通過指標從前往後一步一步的銷燬。每次都銷燬L,即銷燬頭結點,但是銷燬頭結點,後面的資料就殘留在儲存器中。為了方便銷燬,定義一個LinkList變數q,協助L進行銷燬工作。通過q獲取L的後繼結點,銷燬L後,讓L指向L的後繼,繼續進行銷燬工作,直到所有元素被銷燬。具體步驟如下:
1.定義LinkList變數q。
2.通過迴圈遍歷連結串列,方便進行銷燬;
3. 在迴圈體內,q獲取L的後繼,方便後續銷燬工作。
4. 釋放連結串列的頭結點L,free釋放通過malloc建立的頭結點。
5. 讓L指向q,即實現指標後移。
6.進入下一次迴圈,執行操作。直到L為空
Status DestroyList(LinkList &L) {
LinkList q;
while (L)
{
q = L->next;
free(L);
L = q;
}
return OK;
}
3.將線性表置空L
從連結串列角度分析,空表和銷燬的區別在於,空表有頭結點,但是後繼結點為空。銷燬表後,頭結點為空。所以在置空連結串列的時候,需要從L的後繼結點開始,把所有的資料銷燬。
所以每次銷燬的時候,L不能動,需要兩個LinkList 變數作為輔助,逐步銷燬。其實通過分析發現,我們可以把置空看做一個頭結點,指向了一個有頭結點的連結串列,需要將L指向的這個連結串列銷燬。所以在置空表中,可以參考銷燬表的方式
1.建立兩個變數p,q。其中p指向L的後繼。然後看做是以p為頭結點的列表。
2.通過迴圈遍歷連結串列,方便進行置空;
3. 在迴圈體內,q獲取p的後繼,方便後續銷燬工作。
4. 釋放連結串列的結點p,free釋放通過malloc建立的頭結點。
5. 讓p指向q,即實現指標後移。
6.進入下一次迴圈,執行操作。直到除了L的所有結點全部銷燬。
7.頭結點指向空。
Status ClearList(LinkList &L) {
LinkList p,q;
p = L->next;
while (p)
{
q = p->next;
free(p);
p = q;
}
L->next = NULL;
return OK;
}
這個時候,我們應該就會注意到如下幾個問題了:
1.銷燬與置空的區別是什麼?
這個問題挺幼稚,但是我們必須要弄明白,比如將一棟房子置空,只是將這個房子中所有的傢俱等一系列東西清除出去,但是這個房子還在,銷燬不同,銷燬是將這個房子一起銷燬了,不僅房子本身不在了,房子所屬的地址也將被釋放,用作其他用處。
上面這個是線性表的區別,順序表和連結串列還有各自的區別。主要體現在置空的區別。
2.置空連結串列和置空順序表不一樣?
置空順序表,只需要長度是0,就可以,後面的所有資料沒有操作;
置空連結串列,不僅僅是讓L的後繼為空。還需要銷燬每一個數據結點;
既然都是線性表,他們兩個為什麼會在置空有差別,主要是因為對結點的定義和操作不同。
3.為什麼順序表不需要銷燬每一個結點呢?
連結串列和順序表進行置空的操作的時候的不同的根本原因在於建立方式不同。
順序表在建立的時候,所有元素用的是同一塊基址。(心裡默默的想起來《迪迦奧特曼》之伊路德人,共用一個大腦),所以在置空的時候,其實置空的是地址,所以地址無需銷燬。
連結串列則不同,創立的時候,只有頭結點自己的地址。每增加一個元素,創立一個新的地址,每個結點都有自己獨立的地址,所以在置空的時候,必須要把除了頭結點的每個結點的地址也銷燬。
所以置空的本質是銷燬除基地址外的地址。
4.判斷線性表L是否為空
空表的特徵為:表的後繼為空,所以只需要判別L的next是否為空就好。
Status ListEmpty(LinkList &L) {
if (L->next)
return TRUE;
else
return FALSE;
}
5.返回線性表中L的資料元素的個數
獲取連結串列的長度的方式是通過指標遍歷,直到最後一個元素。L是頭結點,一般不放元素,所以計算長度不包括L,從L->next開始計算。步驟如下:
1.建立一個變數i,負責統計遍歷次數,即連結串列長度。建立一個變數p,指向L的後繼,並做迴圈遍歷。
2.迴圈中,第一個迴圈,如果p不為空,那就i+ 1,變成1;p指向p的後繼,i+1。直到p為空。
3.返回i的值,即表的長度。
Status ListLength(LinkList &L) {
int i = 0;
LinkList p = L->next;
while (p)
{
i++;
p = p->next;
}
return i;
}
6.用e返回線性表L中第i個元素
用e返回線性表中的第i個元素,用連結串列來實現,則需要從前往後遍歷。在遍歷查詢第i個位置的元素時,可能i超過L的長度,這個時候,在遍歷過程中,p會在某一個位置指向空;
所以在遍歷過程中要判斷當前位序是否小於i並且當前指標是否不為空。當這兩個都滿足的時候,進行遍歷,一方面要讓定義的變數j++,向後遍歷,與i進行比較。一方面要讓p指向其本身的後繼。步驟如下:
1.定義變數j,負責和i進行比較,定義一個指標變數p,指向L的後繼,方便進行迴圈遍歷。
2.如果p不為空,並且當前位序j小於要找的位置,可以指向後繼,進行遍歷。p要指向後繼,j要++。
(如果i是1,p指向L的後繼,這個時候p指向的資料就是要用e 返回的資料,這個時候不做這個迴圈,如果i>1,那就需要直到j = i時,跳出迴圈)
3.除了上面的情況之外,還應該有兩種情況:
(1)p指向空,或者 j > i,這時候,說明沒有找到第i個位置的元素,這時候就要返回錯誤。
(2)p不指向空,並且 j = i,這個時候,說明當前位置的資料就是要查詢的資料,用形參e接收p指向的資料,並返回正確。
Status GetElem(LinkList L, int i, ElemType &e) {
int j = 1;
LinkList p = L->next;
while (p && j < i) {
j++;
p = p->next;
}
if (!p || j > i)
return ERROR;
e = p->data;
return OK;
}
7.返回L中第一個與e滿足關係compare()的資料元素的位序,若不存在則返回0。
連結串列需要迴圈遍歷查詢,遍歷過程中需要做判斷,一個判斷是用於遍歷的指標是否指向空,另一個是當前位置的資料是否與e滿足關係compare。
如果存在這樣的元素,用一個變數 i 來返回位序。
1.定義變數i,負責返回位序,定義一個指標變數p,指向L的後繼,方便進行迴圈遍歷,並判斷p指向的位置的資料元素與e是否滿足關係compare。
2.做迴圈遍歷,如果p為空,退出迴圈,返回錯誤。在遍歷的過程中,如果找到了第一個滿足的元素,就返回當前的位置,如果沒有找到,繼續遍歷。(如果L的後繼滿足關係,那就應該返回1,所以最開始的i為0)
注:該方法查詢到的是滿足compare的第一個元素e。
Status LocateElem(LinkList L, ElemType e, Status(*compare)(ElemType, ElemType)) {
int i = 0;
LinkList p = L->next;
while (p)
{
i++;
if (compare(e,p->data))
return i;
p = p->next;
}
return ERROR;
}
按照位序查詢和按照元素查詢,對於連結串列是否不同?
我們知道對於順序表的查詢,按照位置查詢和按照元素查詢有兩方面不同。
1.兩個方法的查詢方式不同,如果是按照位置查詢,直接用位序相關關係求即可,無需遍歷。但是按照元素查詢,需要從第一個元素開始遍歷,直到查到第一個滿足compare()位序的元素或者查詢到結尾。
2.兩種方法的時間複雜度是不同的。按照位置查詢屬於隨機存取,時間複雜度為o(1),按照元素查詢,最壞情況下會遍歷到最後一個元素,時間複雜度為o(n)。
對於連結串列來說,這兩種方式都需要進行遍歷,所以查詢方式沒有區別,只是查詢的內容不同,一個是查詢位置,一個是查詢元素。兩者的時間複雜度是相同的,都是遍歷查詢,時間複雜度都是o(n)。所以我們可以認為對於連結串列來說這兩種方式沒有差別。
函式中第三個引數的含義是什麼?
請參考順序表:第三個引數的含義,與順序表一致。
接下來的8和9和6和7比較相似,都是查詢,不同在於6是通過位置找元素,7是通過元素和關係找位置,8和9是確保存在某元素時找到它的前驅或後繼。
8.若L中的cur_e不是L的頭結點,用pre_e返回cur_e的前驅。否則失敗
要返回L的前驅,與位序無關,也不需要返回位序,就不需要考慮i或者j 的初始位置了。
頭結點是沒有前驅的,所以如果元素是L的後繼的資料的時候,錯誤,如果不是就可以做遍歷,p從L的後繼開始遍歷因為要找前驅,普通連結串列(不是雙向連結串列的連結串列)不能直接指向前驅,為了方便運算,那就判斷p的後繼的資料是不是要找的資料,如果是那就返回p指向的資料。如果找不到,就返回錯誤。
1.判斷cur_e是否為L的頭結點。
2.通過指標變數做迴圈遍歷,若p有後繼,說明p可能指向cur_e的前驅。
Status PriorElem(LinkList L, ElemType cur_e, ElemType &pre_e) {
if (cur_e == L->next->data)
return ERROR;
LinkList p = L->next;
while (p->next)
{
if (p->next->data == cur_e)
{
pre_e = p->data;
return TRUE;
}
p = p->next;
}
return ERROR;
}
9.若L中的cur_e不是L的尾結點,用next_e返回cur_e的後繼。
要返回L某元素的後繼,需要從第一個開始查詢,查詢到元素後,直接獲取後繼即可。步驟如下:
1.定義一個指標變數,從L的第一個元素開始查詢;
2.如果p的後繼是空,說明已經查詢到了資料表的結尾,p沒有後繼,所以如果p->next為空時,就不做迴圈。
3.在迴圈體內,判斷p指標指向地址的資料元素是否為要查詢元素,如果是,獲取p的後繼的資料域的資料元素。如果不是,p指向p的後繼繼續查詢。
Status NextElem(LinkList L, ElemType cur_e, ElemType &next_e) {
LinkList p = L;
while (p->next)
{
if (p->data == cur_e)
{
next_e = p->next->data;
return TRUE;
}
p = p->next;
}
return ERROR;
}
10.在L中的第i個位置之前插入新的資料元素e,L的長度+1;
連結串列插入資料元素比較好的一點在於插入位置之後的元素不需要再移動,只需要將新元素的指標指向第i個元素,再將i的前驅元素指標指向新元素即可。
1.定義一個指標變數,從L的第一個元素開始查詢,即L的後繼;這個時候查詢的是位置,而不是元素,所以查詢時同時要獲取元素位序。
在這裡我想採用一個新的方法,我不建立新的int變數,讓位序每次減一,即獲取當前查詢位置到位置 i 之間的距離,當距離為0時,說明查詢到該元素,這樣能節省一個額外空間,減少記憶體消耗。
因為我們希望得到的是 i 的前驅,所以最開始,我們還要 -1 。比如 i = 2,我們要找到 i = 1 的位置,即找到L的後繼,最開始也剛好是L的後繼,--i 的值是0,不做迴圈,當 i = 3 時,要找到 i = 2 的位置,做一次迴圈,i = 1,第二次迴圈 i = 0 ,不做迴圈。
除了上述所說,如果輸入的是非法數值呢?
在這裡,我們先不考慮使用者輸入非整型資料,如果使用者輸入的是0,或者是負值,我們可以通過i-->0來區別。如果使用者輸入了大於表長的數,p最終會成為空,即跳出迴圈,這個時候,我們在迴圈語句後面加上一個判斷語句即可。如果p == null,或 i<=0,返回溢位。
2.找到 i 的前驅,然後就需要將新元素插入到連結串列中,定義一個指標q,資料域上的資料為e,q的next指標指向p的next,然後p的後繼為q。
Status ListInsert(LinkList &L, int i, ElemType e) {
LinkList p = L->next;
i--;
while (p && i > 0) {
p = p->next;
i--;
}
if (p == NULL || i<0)
return OVERFLOW;
LinkList q = (LinkList)malloc(sizeof(LNode));
q->data = e;
q->next = p->next;
p->next = q;
return OK;
}
11.刪除L中的第i個元素,並用e返回它的值,L的長度-1;
刪除,需要將第i個元素結點釋放,將第i個結點的後繼變為i結點的前驅的後繼。
1.找到結點 i 的前驅,理論同插入元素一樣,不再過多陳述。
2.獲取到i結點的前驅的後繼,(即i結點本身)用q指標指向該結點,將結點暫存下來,方便後續的釋放結點。
3.用 e 獲取到 i 結點的資料, i 結點的前驅指向i結點的後繼,並釋放 q 結點。
Status ListDelete(LinkList &L, int i, ElemType &e) {
LinkList p = L->next;
i--;
while (p && i > 1) {
p = p->next;
i--;
}
if (p == NULL || i < 0)
return OVERFLOW;
LinkList q;
q = p->next;
e = q->data;
p->next = q->next;
free(q);
return e;
}
對於順序表來說,上面這些操作就可以了,但是對於連結串列來說,我們應該還要講三個重要操作:頭插法建立單鏈表,尾插法建立單鏈表,遍歷連結串列元素。
3、程式碼應用
1.頭插法建立單鏈表
頭插法建立單鏈表是連結串列插入元素的應用,即在表頭結點後面插入元素。在這裡只給出程式碼,請大家自己分析。有什麼問題我們可以共同交流。
Status HeadInsertList(LinkList &L, ElemType e) {
LinkList p = (LinkList)malloc(sizeof(LNode));
p->data = e;
p->next = L->next;
L->next = p;
return OK;
}
2.尾插法建立單鏈表
Status TailInsertList(LinkList &L, ElemType e) {
LinkList p = L;
LinkList q = (LinkList)malloc(sizeof(LNode));
q->data = e;
q->next = NULL;
while (p->next)
p = p->next;
p->next = q;
p = q;
return OK;
}
頭插法和尾插法哪個好用?
頭插法和尾插法都是插入元素的應用,通過比較我們發現它們最大的差別主要在於是否需要遍歷連結串列。頭插法和尾插法是插入元素的兩個極端,頭插法無需遍歷連結串列,尾插法需要遍歷整個連結串列。所以一般情況下,為了使時間複雜度最小,採用頭插法建立單鏈表。
3.遍歷輸出連結串列元素
void OutputList(LinkList L) {
LinkList p = L->next;
int i = 0;
while (p)
{
cout << p->data << '\t';
i++;
if (i%5 == 0)
{
cout << endl;
}
p = p->next;
}
cout << endl;
}
4.函式程式碼呼叫
接下來我們測試一下我們寫的函式。
void main() {
LinkList L;
ElemType e = 0;
InitList(L);
//——————頭插法建立單鏈表——————
cout << "頭插法建立單鏈表。\n";
for (int i = 5; i > 0; i--) {
HeadInsertList(L, i);
}
OutputList(L);
//——————尾插法建立單鏈表——————
cout << "尾插法建立單鏈表。\n";
for (int i = 6; i < 10; i++) {
TailInsertList(L, i);
}
OutputList(L);
//——————刪除第五個元素——————
cout << endl;
cout << "刪除第五個元素。\n";
ListDelete(L, 5, e);
cout << "刪除的位置的元素是" << e << endl;
OutputList(L);
}
【輸出結果】
分析碼字不易,希望大家喜歡。