線性表的鏈式儲存結構的特點是用一組任意的儲存單元儲存線性表的資料元素,這組儲存單元可以是連續的,也可以是不連續的。這就意味著,這些資料元素可以存在記憶體未被佔用的任意位置(如圖所示)。
以前在順序結構中,每個資料元素只需要存資料元素資訊就可以了。現在鏈式結構中,除了要存資料元素資訊外,還要儲存它的後繼元素的儲存地址。
我們把儲存資料元素資訊的域稱為資料域,把儲存直接後繼位置的域稱為指標域。指標域中儲存的資訊稱做指標或鏈。這兩部分資訊組成資料元素ai的儲存映像,稱為結點(Node)。
n個結點(ai的儲存映像)鏈結成一個連結串列,即為線性表(a1,a2,...,an)的鏈式儲存結構,因為此連結串列的每個結點中只包含一個指標域,所以叫做單連結串列。單連結串列正是通過每個結點的指標域將線性表的資料元素按其邏輯次序連結在一起,如圖所示。
對於線性表來說,總得有個頭有個尾,連結串列也不例外。我們把連結串列中第一個結點的儲存位置叫做頭指標,那麼整個連結串列的存取就必須是從頭指標開始進行了。之後的每一個結點,其實就是上一個的後繼指標指向的位置。想象一下,最後一個結點,它的指標指向哪裡?
最後一個,當然就意味著直接後繼不存在了,所以我們規定,線性連結串列的最後一個結點指標為“空”(通常用NULL或“^”符號表示,如圖所示)。
有時,我們為了更加方便地對連結串列進行操作,會在單連結串列的第一個結點前附設一個結點,稱為頭結點。頭結點的資料域可以不儲存任何資訊,誰叫它是第一個呢,有這個特權。也可以儲存如線性表的長度等附加資訊,頭結點的指標域儲存指向第一個結點的指標,如圖所示。
頭指標與頭結點的異同
線性錶鏈式儲存結構程式碼描述
若線性表為空,則頭結點的指標域指為空,如圖所示:
單連結串列的儲存示意圖:
帶有頭結點的單連結串列,如圖所示:
單連結串列中,我們在C語言中可用結構指標來描述。
// 儲存空間初始分配量
#define MAXSIZE 20
// ElemtType型別根據實際情況而定,這裡假設為int
typedef int ElemType;
typedef struct {
// 陣列儲存資料的元素,最多為MAXSIZE個
ElemType data[MAXSIZE];
// 線性表當前的長度
int length;
} SqList;
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
// Status是函式的型別,其值是函式結果狀態程式碼,如OK等
typedef int Status;
// 線性表的單連結串列儲存結構
typedef struct NodeTag {
ElemType data;
struct NodeTag *next;
} Node;
typedef struct NodeTag *LinkList;
複製程式碼
從這個結構定義中,我們也就知道,結點由存放資料元素的資料域和存放後繼結點地址的指標域組成。假設p是指向線性表第i個元素的指標,則該結點ai的資料域我們可以用p->data來表示,p->data的值是一個資料元素,結點ai的指標域可以用p->next來表示,p->next的值是一個指標。p->next指向誰呢?當然是指向第i+1個元素,即指向ai+1的指標。也就是說,如果p->data=ai,那麼p->next->data=ai+1(如圖所示)。
單連結串列的讀取
獲得單連結串列第i個元素的思路:
- 宣告一個指標p指向單連結串列的第一個結點,初始化j從1開始。
- 當j<i時,就遍歷連結串列,讓p的指標向後移動,不斷指向下一個結點。j累加1。
- 如果到連結串列的末尾,p為空,則第i結點不存在。
- 否則查詢成功,返回結點p的資料,也就是第i個結點的資料。
實現演算法程式碼如下:
// 初始條件:順序線性表L已存在,1≤i≤ListLength(L)
// 操作結果:用e返回L中第i個資料元素的值
Status GetElem(LinkList L, int i, ElemType *e) {
LinkList p = NULL;
int j = 1;
while (p && j < i) {
p = p->next;
j++;
}
if (!p || j > i) {
return ERROR;
}
*e = p->data;
return OK;
}
複製程式碼
單連結串列的插入
先來看單連結串列的插入。假設儲存元素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,這樣真正的擁有ai+1資料元素的結點就沒了上級。這樣的插入操作就是失敗的,造成了臨場掉鏈子的尷尬局面。所以這兩句是無論如何不能反的,這點初學者一定要注意。
插入結點s後,連結串列如圖所示。
對於單連結串列的表頭和表尾的特殊情況,操作是相同的,如圖所示。
單連結串列第i個資料插入結點的演算法思路:
- 宣告一指標p指向連結串列頭結點,初始化j從1開始;
- 當j<i時,就遍歷連結串列,讓p的指標向後移動,不斷指向下一結點,j累加1;
- 若到連結串列末尾p為空,則說明第i個結點不存在;
- 否則查詢成功,在系統中生成一個空結點s;
- 將資料元素e賦值給s->data;
- 單連結串列的插入標準語句s->next=p->next;p->next=s;
- 返回成功。
程式碼實現:
// 初始條件:順序線性表L已存在,1≤i≤ListLength(L),
// 操作結果:在L中第i個結點位置之前插入新的資料元素e,L的長度加1
Status ListInsert(LinkList *L, int i, ElemType e) {
LinkList p = *L;//讓p指向連結串列的頭結點
LinkList s;//新元素的結點
int j = 1;//從第一個位置開始便利
//尋找第i-1個結點
while (p && j < i) {
p = p->next;
j++;
}
//如果傳入的i是0或者是負數,那麼這個時候,這個i節點是不存在的
if (!p || j > i) {
return ERROR;
}
//建立新的結點
s = (LinkList)malloc(sizeof(Node));
s->data = e;
//將p的後繼結點賦值給s的後繼
s->next = p->next;
//將s賦值給p的後繼
p->next = s;
return OK;
}
複製程式碼
單連結串列刪除
設儲存元素ai的結點為q,要實現將結點q刪除單連結串列的操作,其實就是將它的前繼結點的指標繞過,指向它的後繼結點即可,如圖所示。
我們所要做的,實際上就是一步,p->next=p->next->next,用q來取代p->next,即是:
q=p->next; p->next=q->next;
複製程式碼
單連結串列第i個資料刪除結點的演算法思路:
- 宣告一指標p指向連結串列頭結點,初始化j從1開始;
- 當j<i時,就遍歷連結串列,讓p的指標向後移動,不斷指向下一個結點,j累加1;
- 若到連結串列末尾p為空,則說明第i個結點不存在;
- 否則查詢成功,將欲刪除的結點p->next賦值給q;
- 單連結串列的刪除標準語句p->next=q->next;
- 將q結點中的資料賦值給e,作為返回;
- 釋放q結點;
- 返回成功。
程式碼實現:
// 初始條件:順序線性表L已存在,1≤i≤ListLength(L)
// 操作結果:刪除L的第i個結點,並用e返回其值,L的長度減1
Status ListDelete(LinkList *L, int i, ElemType *e) {
LinkList p = *L;
// 要刪除的結點
LinkList q;
int j = 1;
// 找到要刪除結點的前一個結點
while (p && i > j) {
p = p->next;
j++;
}
//i應該大於等於1,為0或者負數時,節點不存在, 判斷要刪除的結點是否存在
if (!(p->next) || j > i) {
return ERROR;
}
q = p->next;
p->next = q->next;
*e = q->data;
//釋放q結點
free(q);
return OK;
}
複製程式碼
單連結串列的整表建立
單連結串列整表建立的演算法思路:
- 宣告一指標p和計數器變數i;
- 初始化一空連結串列L;
- 讓L的頭結點的指標指向NULL,即建立一個帶頭結點的單連結串列;
- 迴圈:
頭插法
- 隨機生成一個新的結點賦值給p;
- 隨機生成一個數字賦值給p的資料域;
- 將p插入到頭結點與前一個新結點之間。
程式碼實現如下:
// 隨機產生n個元素的值,建立帶表頭結點的單鏈線性表L(頭插法
void CreateListHead(LinkList *L, int n) {
LinkList p;
//初始化頭結點
*L = (LinkList)malloc(sizeof(Node));
(*L)->next = NULL;
for (int i = 0; i < n; i++) {
// 生成新的結點
p = (LinkList)malloc(sizeof(Node));
// 隨機生成100以內的數字
p->data = rand() % 100 + 1;
p->next = (*L)->next;
// 插入到表頭
(*L)->next = p;
}
}
複製程式碼
這段演算法程式碼裡,我們其實用的是插隊的辦法,就是始終讓新結點在第一的位置。我也可以把這種演算法簡稱為頭插法,如圖所示:
尾插法
可事實上,我們還是可以不這樣幹,為什麼不把新結點都放到最後呢,這才是排隊時的正常思維,所謂的先來後到。我們把每次新結點都插在終端結點的後面,這種演算法稱之為尾插法。
演算法實現思路:
- 宣告一個指標r,用來記錄尾結點;初始化時r指向頭結點的位置;
- 隨機生成一個新的結點賦值給p;
- 給p的資料域隨機賦值;
- 將r的後繼賦值為p;
- 這是p是尾結點,我們將p再賦值給r,以便於下次能夠快速找到尾結點
實現程式碼演算法如下:
// 隨機產生n個元素的值,建立帶表頭結點的單鏈線性表L(尾插法)
void CreateListTail(LinkList *L, int n) {
LinkList p,r;
//初始化頭結點
*L = (LinkList)malloc(sizeof(Node));
(*L)->next = NULL;
//r為指向尾部的結點
r = *L;
for (int i = 0; i < n; i++) {
// 生成新的結點
p = (LinkList)malloc(sizeof(Node));
// 隨機生成100以內的數字
p->data = rand() % 100 + 1;
r->next = p;
r = p;
}
}
複製程式碼
這段程式碼裡我們通過把新元素追加到尾部的方法,讓新的元素始終維持在連結串列的末尾,我們稱之為尾插法,如圖所示:
單連結串列的整表刪除
當我們不打算使用這個單連結串列時,我們需要把它銷燬,其實也就是在記憶體中將它釋放掉,以便於留出空間給其他程式或軟體使用。
單連結串列整表刪除的演算法思路如下:
- 宣告指標p和q;
- 將第一個結點賦值給p;
- 迴圈:
- 將p->next賦值給q;
- 釋放p結點;
- 將q賦值給p;
程式碼實現如下:
// 初始條件:順序線性表L已存在,操作結果:將L重置為空表
Status ClearList(LinkList *L) {
LinkList p, q;
p = (*L)->next;
while (p) {
q = p->next;
free(p);
p = q;
}
// 頭結點的指標域為空
p->next = NULL;
return OK;
}
複製程式碼
”這段演算法程式碼裡,常見的錯誤就是覺得q變數沒有存在的必要。在迴圈體內直接寫free(p); p = p->next;即可。可這樣會帶來什麼問題?
要知道p指向一個結點,它除了有資料域,還有指標域。你在做free(p);時,其實是在對它整個結點進行刪除和記憶體釋放的工作。這就好比皇帝快要病死了,卻還沒有冊封太子,他兒子五六個,你說要是你腳一蹬倒是解脫了,這國家咋辦,你那幾個兒子咋辦?這要是為了皇位,什麼親兄弟血肉情都成了浮雲,一定會打起來。所以不行,皇帝不能馬上死,得先把遺囑寫好,說清楚,哪個兒子做太子才行。而這個遺囑就是變數q的作用,它使得下一個結點是誰得到了記錄,以便於等當前結點釋放後,把下一結點拿回來補充。“
摘錄來自: 程傑. “大話資料結構。” Apple Books.
總結
簡單地對單連結串列結構和順序儲存結構做對比:
通過上面的對比,我們可以得出一些經驗性的結論:
- 若線性表需要頻繁查詢,很少進行插入和刪除操作時,宜採用順序儲存結構。
- 若需要頻繁插入和刪除時,宜採用單連結串列結構。
- 當線性表中的元素個數變化較大或者根本不知道有多大時,最好用單連結串列結構,這樣可以不需要考慮儲存空間的大小問題。
- 而如果事先知道線性表的大致長度,比如一年12個月,一週就是星期一至星期日共七天,這種用順序儲存結構效率會高很多。