線性表的鏈式儲存與刪除
頭指標:
a.頭指標是指連結串列指向第一個結點的指標,若連結串列有頭結點,則是指向頭結點的指標
b.頭指標具有標識作用,所以頭指標冠以連結串列的名字(指標變數的名字)
c.無論連結串列是否為空,頭指標均不為空
d.頭指標是連結串列的必要元素
頭結點:
a.頭結點是為了操作的統一和方便而設立的,放在第一個元素的結點之前,其資料域一般無意義(但也可以用來存放連結串列的長度)
b.有了頭結點,對在第一元素結點前插入結點和刪除第一結點起操作與其它結點的操作就統一了
c.頭結點不一定是連結串列的必要元素
2.單鏈表的讀取
線上性表的順序儲存結構中,我們要計算任意一個元素的儲存位置是很容易的。但在單鏈表中,由於第i個元素到底在哪?沒辦法一開始就知道,必須得從頭開始找。因此,對於單鏈表實現獲取第i個元素的資料的操作GetElem,在演算法上,相對要麻煩一些。
獲得連結串列第i個數據的演算法思路:
a.宣告一個指標p指向連結串列第一個結點,初始化j從1開始;
b.當j<i時,就遍歷連結串列,讓p的指標向後移動,不斷指向下一結點,j累加1;
c.若到連結串列末尾p為空,則說明第i個結點不存在;
d.否則查詢成功,返回結點p的資料。
實現程式碼演算法如下:
/* 初始條件:順序線性表L已存在,1≤i≤
ListLength(L) */
/* 操作結果:用e返回L中第i個數據元素的值 */
Status GetElem(LinkList L, int i, ElemType *e)
{
int j;
LinkList p; /* 宣告一指標p */
p = L->next; /* 讓p指向連結串列L的第個結點 */
j = 1; /* j為計數器 */
/* p不為空且計數器j還沒有等於i時,迴圈繼續 */
while (p && j < i)
{
p = p->next; /* 讓p指向下一個結點 */
++j;
}
if (!p || j > i)
return ERROR; /* 第i個結點不存在 */
*e = p->data; /* 取第i個結點的資料 */
return OK;
}
說白了,就是從頭開始找,直到第i個結點為止。由於這個演算法的時間複雜度取決於i的位置,當i=1時,則不需遍歷,第一個就取出資料了,而當i=n時則遍歷n-1次才可以。因此最壞情況的時間複雜度是O(n)。
由於單鏈表的結構中沒有定義表長,所以不能事先知道要迴圈多少次,因此也就不方便使用for來控制迴圈。其主要核心思想就是“工作指標後移”,這其實也是很多演算法的常用技術。
3.單鏈表的插入
先來看單鏈表的插入。假設儲存元素e的結點為s,要實現結點p、p->next和s之間邏輯關係的變化,只需將結點s插入到結點p和p->next之間即可。
根本用不著驚動其他結點,只需要讓s->next和p->next的指標做一點改變即可。
s->next = p->next; p->next = s;
解讀這兩句程式碼,也就是說讓p的後繼結點改成s的後繼結點,再把結點s變成p的後繼結點
如果先p->next=s;再s->next=p->next;會怎麼樣?因為此時第一句會使得將p->next給覆蓋成s的地址了。那麼s->next=p->next,其實就等於s->next=s。這樣的插入操作就是失敗的,造成了臨場掉鏈子的尷尬局面。所以這兩句是無論如何不能反的,這點初學者一定要注意。
單鏈表第i個數據插入結點的演算法思路: 1.宣告一指標p指向連結串列頭結點,初始化j從1開始; 2.當j<i時,就遍歷連結串列,讓p的指標向後移動,不斷指向下一結點,j累加1; 3.若到連結串列末尾p為空,則說明第i個結點不存在; 4.否則查詢成功,在系統中生成一個空結點s; 5.將資料元素e賦值給s->data; 6.單鏈表的插入標準語句s->next=p->next;p->next=s; 7.返回成功。
實現程式碼演算法如下:
/* 初始條件:順序線性表L已存在,1≤i≤
ListLength(L), */
/* 操作結果:在L中第i個結點位置之前插入新的數
據元素e,L的長度加1 */
Status ListInsert(LinkList *L, int i, ElemType e)
{
int j;
LinkList p, s;
p = *L;
j = 1;
/* 尋找第i-1個結點 */
while (p && j < i)
{
p = p->next;
++j;
}
/* 第i個結點不存在 */
if (!p || j > i)
return ERROR;
/* 生成新結點(C標準函式) */
s = (LinkList)malloc(sizeof(Node));
s->data = e;
/* 將p的後繼結點賦值給s的後繼 */
s->next = p->next;
/* 將s賦值給p的後繼 */
p->next = s;
return OK;
}
在這段演算法程式碼中,我們用到了C語言的mal-loc標準函式,它的作用就是生成一個新的結點,其型別與Node是一樣的,其實質就是在記憶體中找了一小塊空地,準備用來存放資料e的s結點。
4.單鏈表的刪除
單鏈表第i個數據刪除結點的演算法思路:
a.宣告一指標p指向連結串列頭結點,初始化j從1開始;
b.當j<i時,就遍歷連結串列,讓p的指標向後移動,不斷指向下一個結點,j累加1;
c.若到連結串列末尾p為空,則說明第i個結點不存在;
d.否則查詢成功,將欲刪除的結點p->next賦值給q;
e.單鏈表的刪除標準語句p->next=q->next;
f.將q結點中的資料賦值給e,作為返回;
g.釋放q結點;
h.返回成功。
實現程式碼演算法如下:
/* 初始條件:順序線性表L已存在,1≤i≤
ListLength(L) */
/* 操作結果:刪除L的第i個結點,並用e返回其
值,L的長度減1 */
Status ListDelete(LinkList *L, int i, ElemType *e)
{
int j;
LinkList p, q;
p = *L;
j = 1;
/* 遍歷尋找第i-1個結點 */
while (p->next && j < i)
{
p = p->next;
++j;
}
/* 第i個結點不存在 */
if (!(p->next) || j > i)
return ERROR;
q = p->next;
/* 將q的後繼賦值給p的後繼 */
p->next = q->next;
/* 將q結點中的資料給e */
*e = q->data;
/* 讓系統回收此結點,釋放記憶體 */
free(q);
return OK;
}
這段演算法程式碼裡,我們又用到了另一個C語言的標準函式free。它的作用就是讓系統回收一個Node結點,釋放記憶體。
分析一下剛才我們講解的單鏈表插入和刪除演算法,我們發現,它們其實都是由兩部分組成:第一部分就是遍歷查詢第i個結點;第二部分就是插入和刪除結點。
從整個演算法來說,我們很容易推匯出:它們的時間複雜度都是O(n)。如果在我們不知道第i個結點的指標位置,單鏈表資料結構在插入和刪除操作上,與線性表的順序儲存結構是沒有太大優勢的。但如果,我們希望從第i個位置,插入10個結點,對於順序儲存結構意味著,每一次插入都需要移動n-i個結點,每次都是O(n)。而單鏈表,我們只需要在第一次時,找到第i個位置的指標,此時為O(n),接下來只是簡單地通過賦值移動指標而已,時間複雜度都是O(1)。顯然,對於插入或刪除資料越頻繁的操作,單鏈表的效率優勢就越是明顯。
5.單鏈表的整表建立
單鏈表整表建立的演算法思路:
a.宣告一指標p和計數器變數i;
b.初始化一空連結串列L;
c.讓L的頭結點的指標指向NULL,即建立一個帶頭結點的單鏈表;
d.迴圈:
生成一新結點賦值給p;
隨機生成一數字賦值給p的資料域p->data;
將p插入到頭結點與前一新結點之間。
實現程式碼演算法如下:
/* 隨機產生n個元素的值,建立帶表頭結點的單鏈
線性表L(頭插法) */
void CreateListHead(LinkList *L, int n)
{
LinkList p;
int i;
/* 初始化隨機數種子 */
srand(time(0));
*L = (LinkList)malloc(sizeof(Node));
/* 先建立一個帶頭結點的單鏈表 */
(*L)->next = NULL;
for (i = 0; i < n; i++)
{
/* 生成新結點 */
p = (LinkList)malloc(sizeof(Node));
/* 隨機生成100以內的數字 */
p->data = rand() % 100 + 1;
p->next = (*L)->next;
/* 插入到表頭 */
(*L)->next = p;
}
}
這段演算法程式碼裡,我們其實用的是插隊的辦法,就是始終讓新結點在第一的位置。我也可以把這種演算法簡稱為頭插法.
可事實上,我們還是可以不這樣幹,為什麼不把新結點都放到最後呢,這才是排隊時的正常思維,所謂的先來後到。我們把每次新結點都插在終端結點的後面,這種演算法稱之為尾插法。
實現程式碼演算法如下:
/* 隨機產生n個元素的值,建立帶表頭結點的單鏈
線性表L(尾插法) */
void CreateListTail(LinkList *L, int n)
{
LinkList p,r;
int i;
/* 初始化隨機數種子 */
srand(time(0));
/* 為整個線性表 */
*L = (LinkList)malloc(sizeof(Node));
/* r為指向尾部的結點 */
r = *L;
for (i = 0; i < n; i++)
{
/* 生成新結點 */
p = (Node *)malloc(sizeof(Node));
/* 隨機生成100以內的數字 */
p->data = rand() % 100 + 1;
/* 將表尾終端結點的指標指向新結點 */
r->next = p;
/* 將當前的新結點定義為表尾終端結點 */
r = p;
}
/* 表示當前連結串列結束 */
r->next = NULL;
}
注意L與r的關係,L是指整個單鏈表,而r是指向尾結點的變數,r會隨著迴圈不斷地變化結點,而L則是隨著迴圈增長為一個多結點的連結串列。
這裡需解釋一下,r->next=p;的意思,其實就是將剛才的表尾終端結點r的指標指向新結點p.
6.單鏈表的整表刪除
當我們不打算使用這個單鏈表時,我們需要把它銷燬,其實也就是在記憶體中將它釋放掉,以便於留出空間給其他程式或軟體使用。
單鏈表整表刪除的演算法思路如下:
a.宣告一指標p和q;
b.將第一個結點賦值給p;
c.迴圈:
將下一結點賦值給q;
釋放p;
將q賦值給p。
實現程式碼演算法如下:
/* 初始條件:順序線性表L已存在,操作結果:將L
重置為空表 */
Status ClearList(LinkList *L)
{
LinkList p, q;
/* p指向第一個結點 */
p = (*L)->next;
/* 沒到表尾 */
while (p)
{
q = p->next;
free(p);
p=q;
}
/* 頭結點指標域為空 */
(*L)->next = NULL;
return OK;
}
這段演算法程式碼裡,常見的錯誤就是有同學會覺得q變數沒有存在的必要。在迴圈體內直接寫free(p); p = p->next;即可。可這樣會帶來什麼問題?
要知道p指向一個結點,它除了有資料域,還有指標域。你在做free(p);時,其實是在對它整個結點進行刪除和記憶體釋放的工作。這就好比皇帝快要病死了,卻還沒有冊封太子,他兒子五六個,你說要是你腳一蹬倒是解脫了,這國家咋辦,你那幾個兒子咋辦?這要是為了皇位,什麼親兄弟血肉情都成了浮雲,一定會打起來。所以不行,皇帝不能馬上死,得先把遺囑寫好,說清楚,哪個兒子做太子才行。而這個遺囑就是變數q的作用,它使得下一個結點是誰得到了記錄,以便於等當前結點釋放後,把下一結點拿回來補充。
7.靜態連結串列
其實C語言真是好東西,它具有的指標能力,使得它可以非常容易地操作記憶體中的地址和資料,這比其他高階語言更加靈活方便。後來的面嚮物件語言,如Java、C#等,雖不使用指標,但因為啟用了物件引用機制,從某種角度也間接實現了指標的某些作用。但對於一些語言,如Basic、Fortran等早期的程式設計高階語言,由於沒有指標,連結串列結構按照前面我們的講法,它就沒法實現了。怎麼辦呢?
有人就想出來用陣列來代替指標,來描述單鏈表。真是不得不佩服他們的智慧,我們來看看他是怎麼做到的。
首先我們讓陣列的元素都是由兩個資料域組成,data和cur。也就是說,陣列的每個下標都對應一個data和一個cur。資料域data,用來存放資料元素,也就是通常我們要處理的資料;而cur相當於單鏈表中的next指標,存放該元素的後繼在陣列中的下標,我們把cur叫做遊標。
我們把這種用陣列描述的連結串列叫做靜態連結串列,這種描述方法還有起名叫做遊標實現法。
為了我們方便插入資料,我們通常會把陣列建立得大一些,以便有一些空閒空間可以便於插入時不至於溢位。
/* 線性表的靜態連結串列儲存結構 */
/* 假設連結串列的最大長度是1000 */
#define MAXSIZE 1000
typedef struct
{
ElemType data;
/* 遊標(Cursor),為0時表示無指向 */
int cur;
} Component,
/* 對於不提供結構struct的程式設計語言,
可以使用一對並行陣列data和cur來處理。 */
StaticLinkList[MAXSIZE];
另外我們對陣列第一個和最後一個元素作為特殊元素處理,不存資料。我們通常把未被使用的陣列元素稱為備用連結串列。而陣列第一個元素,即下標為0的元素的cur就存放備用連結串列的第一個結點的下標;而陣列的最後一個元素的cur則存放第一個有數值的元素的下標,相當於單鏈表中的頭結點作用,當整個連結串列為空時,則為0。
8.靜態連結串列優缺點
優點:在插入和刪除操作時,只需要修改遊標,不需要移動元素,從而改進了在順序儲存結構中的插入和刪除操作需要移動大量元素的缺點
缺點:
a.沒有解決連續儲存分配帶來的表長 難以確定的問題
b.失去了順序儲存結構隨機存取的特性
9.迴圈連結串列
將單鏈表中終端結點的指標端由空指標改為指向頭結點,就使整個單鏈表形成一個環,這種頭尾相接的單鏈表稱為單迴圈連結串列,簡稱迴圈連結串列(circular linked list)。
為了使空連結串列與非空連結串列處理一致,我們通常設一個頭結點,當然,這並不是說,迴圈連結串列一定要頭結點,這需要注意。
其實迴圈連結串列和單鏈表的主要差異就在於迴圈的判斷條件上,原來是判斷p->next是否為空,現在則是p->next不等於頭結點,則迴圈未結束。在單鏈表中,我們有了頭結點時,我們可以用O(1)的時間訪問第一個結點,但對於要訪問到最後一個結點,卻需要O(n)時間,因為我們需要將單鏈表全部掃描一遍。有沒有可能用O(1)的時間由連結串列指標訪問到最後一個結點呢?當然可以。不過我們需要改造一下這個迴圈連結串列,不用頭指標,而是用指向終端結點的尾指標來表示迴圈連結串列(如圖3-13-5所示),此時查詢開始結點和終端結點都很方便了。終端結點用尾指標rear指示,則查詢終端結點是O(1),而開始結點,其實就是rear->next->next,其時間複雜也為O(1)。
10.雙向連結串列
我們在單鏈表中,有了next指標,這就使得我們要查詢下一結點的時間複雜度為O(1)。可是如果我們要查詢的是上一結點的話,那最壞的時間複雜度就是O(n)了,因為我們每次都要從頭開始遍歷查詢。為了克服單向性這一缺點,我們的老科學家們,設計出了雙向連結串列。雙向連結串列(double linkedlist)是在單鏈表的每個結點中,再設定一個指向其前驅結點的指標域。所以在雙向連結串列中的結點都有兩個指標域,一個指向直接後繼,另一個指向直接前驅。
/* 線性表的雙向連結串列儲存結構 */
typedef struct DulNode
{
ElemType data;
struct DuLNode *prior; /* 直接前驅指標 */
struct DuLNode *next; /* 直接後繼指標 */
} DulNode, *DuLinkList;
既然單鏈表也可以有迴圈連結串列,那麼雙向連結串列當然也可以是迴圈表。
由於這是雙向連結串列,那麼對於連結串列中的某一個結點p,它的後繼的前驅是誰?當然還是它自己。它的前驅的後繼自然也是它自己,即:
p->next->prior = p = p->prior->next
雙向連結串列是單鏈表中擴展出來的結構,所以它的很多操作是和單鏈表相同的,比如求長度的ListLength,查詢元素的GetElem,獲得元素位置的LocateElem等,這些操作都只要涉及一個方向的指標即可,另一指標多了也不能提供什麼幫助。就像人生一樣,想享樂就得先努力,欲收穫就得付代價。雙向連結串列既然是比單鏈表多瞭如可以反向遍歷查詢等資料結構,那麼也就需要付出一些小的代價:在插入和刪除時,需要更改兩個指標變數。雙向連結串列相對於單鏈表來說,要更復雜一些,畢竟它多了prior指標,對於插入和刪除時,需要格外小心。另外它由於每個結點都需要記錄兩份指標,所以在空間上是要佔用略多一些的。不過,由於它良好的對稱性,使得對某個結點的前後結點的操作,帶來了方便,可以有效提高演算法的時間效能。說白了,就是用空間來換時間。
插入操作時,其實並不複雜,不過順序很重要,千萬不能寫反了。