資料結構與演演算法之連結串列的那些事兒
前言
一名優秀的程式設計師,必然要有紮實的資料結構與演演算法基礎。以下是筆者梳理的資料結構與算法系列,歡迎大家閱讀指正,同時也希望對大家有所幫助。
- 資料結構與演演算法之基礎概念
- 資料結構與演演算法之連結串列的那些事兒
- 未完待續...
連結串列是計算機中比較基礎的一種資料結構,它對應的是資料在計算機上的鏈式儲存。常見的有:單連結串列、雙連結串列、迴圈連結串列,其中迴圈連結串列又分為 單向迴圈連結串列 和 雙向迴圈連結串列。
這次的主題就是連結串列,下面進入正文。
1 單連結串列
單連結串列結構比較簡單,如圖所示
說明如下:
- 單連結串列的每個結點都有 資料域(data)和指標域(next);
- 單連結串列的尾結點的 next指標 指向的是
NULL
,其它結點的 next指標 指向的是當前結點的下一個結點地址; - 單連結串列可以有頭結點(上圖的連結串列有頭結點),也可以沒有;
- 如果有頭結點,則 頭指標 指向的是頭結點地址,頭結點的 next指標 指向的是首元結點地址;
- 如果沒有頭結點,則 頭指標 指向首元結點地址。
思考:為什麼連結串列要設定頭結點?
接下來玩一下單連結串列的常規操作
1.1 準備工作
結點定義以及一些輔助工作
#define MAXSIZE 20
// 操作連結串列時返回的狀態
typedef enum LINKEDLIST_STATUS {
LINKEDLIST_STATUS_FAILURE = 0 ,LINKEDLIST_STATUS_SUCCESS
} Status;
// 資料域的資料型別,這裡假設為 int
typedef int NodeDataType;
typedef struct Node {
NodeDataType data;
struct Node *next;
// 結點數,僅在頭結點有意義
int size;
} Node,*LinkedList;
複製程式碼
1.2 初始化
連結串列初始化,L
是頭指標,這裡要將其指向頭結點。
Status list_init(LinkList *L) {
*L = (LinkList)malloc (sizeof(Node));
if (*L == NULL) return LINKLIST_STATUS_FAILURE;
// 此時還沒有首元結點
(*L)->next = NULL;
(*L)->size = 0;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
1.3 插入結點
單連結串列插入,此時單連結串列L已經存在
主要思路:
- 找到要插入位置的前一個結點,記做preNode;
- 新結點的 next指標 指向 preNode的next結點;
- preNode的next指標 指向新結點。
Status list_insert(LinkList L,int i,NodeDataType data) {
// 合法性判斷
if (L == NULL || L->size >= MAXSIZE || i < 0 || i > L->size)
return LINKLIST_STATUS_FAILURE;
LinkList preNode = L;
int j = 0;
// 尋找目標位置的前一個結點
while (j < i) {
preNode = preNode->next;
++j;
}
// 建立新結點
LinkList node = (LinkList)malloc(sizeof(Node));
if (node == NULL) return LINKLIST_STATUS_FAILURE;
node->data = data;
node->next = preNode->next;
preNode->next = node;
// 更新結點數
L->size++;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
1.4 刪除結點
與插入類似,同樣是要找到preNode。程式碼如下,留意刪除位置的判斷
Status list_delete(LinkList L,int i) {
// 合法性判斷
if (L == NULL || i < 0 || i >= L->size) return LINKLIST_STATUS_FAILURE;
// 尋找目標位置的前一個結點
LinkList preNode = L;
int j = 0;
while (j < i) {
preNode = preNode->next;
++j;
}
// 要刪除的結點
LinkList delNode = preNode->next;
preNode->next = delNode->next;
free(delNode);
// 更新結點數
L->size--;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
1.5 獲取指定位置的資料
從首元結點開始遍歷,直到找到目標位置的結點,其資料用data指標變數接收。
Status list_getData(LinkList L,NodeDataType *data) {
// 合法性判斷
if (L == NULL || i < 0 || i >= L->size) return LINKLIST_STATUS_FAILURE;
// 從首元結點開始遍歷,直到 j == i
LinkList node = L->next;
int j = 0;
while (j < i) {
node = node->next;
++j;
}
// 此時node就是指定位置的結點
*data = node->data;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
1.6 清空連結串列
清空連結串列邏輯比較簡單,從首元結點開始逐個刪除,程式碼如下:
Status list_empty(LinkList L) {
// 合法性判斷
if (L == NULL) return LINKLIST_STATUS_FAILURE;
LinkList delNode = L->next;
while (delNode) {
L->next = delNode->next;
free(delNode);
delNode = L->next;
}
// 最後更新結點數
L->size = 0;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
1.7 銷燬連結串列
最後就是銷燬連結串列,這個也很簡單,同樣上程式碼
Status list_destroy(LinkList *L) {
if (*L == NULL) return LINKLIST_STATUS_FAILURE;
if ((*L)->size > 0) list_empty(*L);
// 釋放頭結點
free(*L);
*L = NULL;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
來到這裡基本上就把連結串列的常用操作介紹完了,其它諸如獲取連結串列結點數、列印連結串列等就不一一列舉了,本文的程式碼工程會放在 github 上,感興趣的同學可以去看看。
1.8 單向迴圈連結串列
如果把單連結串列尾結點的next指標指向頭結點,形成一個閉環,就變成了單向迴圈連結串列。如圖所示
單向迴圈連結串列與單連結串列的區別在於尾結點的next指標指向,前者指向頭結點,後者指向NULL
。因此,單向迴圈連結串列的初始化、插入、刪除、獲取指定位置的結點資料等等操作的程式碼實現,幾乎與單連結串列的一模一樣。具體的程式碼也已上傳到 github,這裡就不多贅述了。
需要注意的是,單向迴圈連結串列遍歷的時候,要進行當前結點是否等於頭結點的判斷,從而避免無限迴圈。
2 雙連結串列
雙向連結串列也叫雙連結串列,它的每個結點中都有兩個指標(next和prior),分別指向直接後繼和直接前驅。如圖所示
接下來就是雙連結串列的常規操作
2.1 準備工作
首先是雙連結串列的結點定義
#define MAXSIZE 20
/// 操作連結串列時返回的狀態
typedef enum LINKLIST_STATUS {
LINKLIST_STATUS_FAILURE = 0,LINKLIST_STATUS_SUCCESS
} Status;
typedef int NodeDataType;
typedef struct Node {
NodeDataType data;
struct Node *prior,*next;
// 結點數,僅在頭結點有意義
int size;
} Node,*LinkList;
複製程式碼
除了多了個prior
指標變數,其它與單連結串列是一樣的。
2.2 初始化
直接上程式碼
Status list_init(LinkList *L) {
*L = (LinkList)malloc(sizeof(Node));
if (*L == NULL) return LINKLIST_STATUS_FAILURE;
// 此時還沒有首元結點,所以頭結點的prior、next指標值為NULL
(*L)->prior = NULL;
(*L)->next = NULL;
(*L)->size = 0;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
對比單連結串列,雙連結串列這裡還需要對頭結點的prior指標賦值為NULL
2.3 插入結點
雙連結串列的結點插入思路與單連結串列大同小異,都是先找到目標位置的前驅結點,然後進行賦值操作,需要注意的是賦值順序別弄錯(目標結點的賦值結束後,再更新其它結點)。
// 插入
Status list_insert(LinkList L,NodeDataType data) {
// 合法性判斷
if (L == NULL || L->size >= MAXSIZE || i < 0 || i > L->size)
return LINKLIST_STATUS_FAILURE;
// 尋找目標位置的前一個結點
LinkList preNode = L;
int j = 0;
while (j < i) {
preNode = preNode->next;
++j;
}
// 建立新結點
LinkList node = (LinkList)malloc(sizeof(Node));
if (node == NULL) return LINKLIST_STATUS_FAILURE;
// 先處理新結點的賦值操作
node->data = data;
node->prior = preNode;
node->next = preNode->next;
// 更新node->next結點的前驅指標
if (node->next) node->next->prior = node;
// 更新preNode的next指標
preNode->next = node;
// 更新結點數
L->size++;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
2.4 刪除結點
思路是先找到preNode,然後更改前結點的next指標和後結點的prior指標,具體程式碼如下:
// 刪除
Status list_delete(LinkList L,int i) {
// 合法性判斷
if (L == NULL || i < 0 || i >= L->size) return LINKLIST_STATUS_FAILURE;
// 尋找目標位置的前一個結點
LinkList preNode = L;
int j = 0;
while (j < i) {
preNode = preNode->next;
++j;
}
// 要刪除的結點
LinkList delNode = preNode->next;
preNode->next = delNode->next;
// 如果delNode不是尾結點
if (delNode->next) delNode->next->prior = preNode;
free(delNode);
// 更新結點數
L->size--;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
雙連結串列的清空、銷燬、獲取指定位置的結點資料等操作與單連結串列一致,這裡就不一一列舉了。
2.5 雙向迴圈連結串列
如果把雙連結串列 頭結點的prior指標指向尾結點,尾結點的next指標指向頭結點,形成一個閉環,就變成了 雙向迴圈連結串列。其結構如下:
特別地,空表的prior和next指標都指向頭結點。
2.5.1 初始化
初始化的結果是一個雙向迴圈連結串列的空表,程式碼如下
// 初始化
Status list_init(LinkList *L) {
*L = (LinkList)malloc(sizeof(Node));
if (*L == NULL) return LINKLIST_STATUS_FAILURE;
// 此時是空表,所以頭結點的prior、next指標都指向自身
(*L)->prior = *L;
(*L)->next = *L;
(*L)->size = 0;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
2.5.2 遍歷
注意遍歷的條件是當前結點不等於頭結點,避免了無限迴圈
// 列印
Status list_print(LinkList L) {
if (!L || L->size == 0) return LINKLIST_STATUS_FAILURE;
LinkList node = L->next;
while (node != L) {
printf("%d\t",node->data);
node = node->next;
}
printf("\n");
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
2.5.3 清空
清空的結果是個空表,程式碼如下
// 清空連結串列
Status list_empty(LinkList L) {
// 合法性判斷
if (L == NULL) return LINKLIST_STATUS_FAILURE;
LinkList delNode = L->next;
while (delNode != L) {
// 更改前驅結點(這裡永遠是頭結點)的next指標指向
L->next = delNode->next;
// 更改後繼結點的next指標指向(永遠指向頭結點)
delNode->next->prior = L;
free(delNode);
delNode = L->next;
}
// 最後更新結點數
L->size = 0;
return LINKLIST_STATUS_SUCCESS;
}
複製程式碼
雙向迴圈連結串列的其它操作(如插入、刪除、獲取指定位置的結點資料)的程式碼實現與雙連結串列的一致,這裡就不多作贅述了。
3 問題討論
3.1 為什麼連結串列要設定頭結點?
答:有以下幾個方面的好處:
- 防止連結串列為
NULL
。當連結串列為空表的時候,對於有頭結點的連結串列,它的頭指標指向的是頭結點;如果不帶頭結點,則連結串列的頭指標為NULL
。 - 為了操作的統一性。在對連結串列進行插入和刪除操作時,如果有頭結點,那麼無論操作的是哪個結點,程式碼邏輯都是一致的;如果沒有頭結點,操作第一個結點與其它結點的邏輯是不同的(操作第一個結點時還會改動到頭指標的指向)。
總的來說,引入頭結點使得連結串列的操作更簡單,減少了bug的出現率。