1. 程式人生 > IOS開發 >資料結構與演演算法之連結串列的那些事兒

資料結構與演演算法之連結串列的那些事兒

前言

一名優秀的程式設計師,必然要有紮實的資料結構與演演算法基礎。以下是筆者梳理的資料結構與算法系列,歡迎大家閱讀指正,同時也希望對大家有所幫助。

  1. 資料結構與演演算法之基礎概念
  2. 資料結構與演演算法之連結串列的那些事兒
  3. 未完待續...

連結串列是計算機中比較基礎的一種資料結構,它對應的是資料在計算機上的鏈式儲存。常見的有:單連結串列雙連結串列迴圈連結串列,其中迴圈連結串列又分為 單向迴圈連結串列雙向迴圈連結串列

這次的主題就是連結串列,下面進入正文。

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已經存在

主要思路:

  1. 找到要插入位置的前一個結點,記做preNode
  2. 新結點的 next指標 指向 preNode的next結點
  3. 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 為什麼連結串列要設定頭結點?

答:有以下幾個方面的好處:

  1. 防止連結串列為NULL。當連結串列為空表的時候,對於有頭結點的連結串列,它的頭指標指向的是頭結點;如果不帶頭結點,則連結串列的頭指標為NULL
  2. 為了操作的統一性。在對連結串列進行插入和刪除操作時,如果有頭結點,那麼無論操作的是哪個結點,程式碼邏輯都是一致的;如果沒有頭結點,操作第一個結點與其它結點的邏輯是不同的(操作第一個結點時還會改動到頭指標的指向)。

總的來說,引入頭結點使得連結串列的操作更簡單,減少了bug的出現率。

4 參考資料

PS

  • 原文連結,轉載請註明出處!
  • 文中所有程式碼都已上傳到 github,且都已經過驗證。