資料結構與演算法學習-連結串列下

by在水一方發表於2019-05-05

前言

上一篇文章講了連結串列相關的概念,這篇主要記錄的是和連結串列相關的演算法以及一些寫好連結串列演算法程式碼相關的技巧

實現單連結串列、迴圈連結串列、雙向連結串列,支援增刪操作

1. 單連結串列


// 標頭檔案 ----------------------------------------------------------
typedef struct _node {
     int value; // 資料域
    struct _node *next; // 指標域
} Node;

typedef struct _list {
    Node *head; // 頭指標
    Node *tail; // 尾指標
    int length; // 連結串列的長度
} SingleList;

// 建立連結串列
SingleList * creatList(void);
// 釋放連結串列
void freeList(SingleList *pList);

// 給連結串列末尾新增一個結點
void appendNode(SingleList *pList, int value);
// 刪除末尾結點
void removeLastNode(SingleList *pList);

// 在指定位置插入一個結點
void insertNodeAtIndex(SingleList *pList, int index, int value);
// 在指定位置刪除一個結點
void deleteNodeAtIndex(SingleList *pList, int index);


// 列印連結串列中所有結點值
void printList(SingleList *pList);

// .m 檔案 --------------------------------------------------------------------
SingleList * creatList(void)
{
    SingleList *pList = (SingleList *)malloc(sizeof(SingleList));
    pList->head = NULL;
    pList->tail = NULL;
    pList->length = 0;

    return pList;
}

// 釋放連結串列
void freeList(SingleList *pList)
{
    if(pList == NULL) {
        return;
    }

    if (pList->head == NULL) {
        free(pList);
        return;
    }

    // 用q 來儲存下一個p節點
    Node *p, *q = NULL;
    for (p = pList->head; p; p = q) {
        q = p->next;
        free(p);
    }

    free(pList);
}

// 給連結串列末尾新增一個結點
void appendNode(SingleList *pList, int value)
{
    // 製造一個結點,加入連結串列中去
    Node *p = (Node *)malloc(sizeof(Node));
    p->value = value;
    p->next = NULL;

    // 如果連結串列為空
    if (pList->head == NULL) {
        // p 結點就是頭結點,也是尾結點
        pList->head = pList->tail = p;
    } else {
        pList->tail->next = p;
        // 更新尾指標
        pList->tail = p;
    }

    pList->length += 1;
}

// 刪除末尾結點
void removeLastNode(SingleList *pList)
{
    if (pList->tail == NULL) {
        // 連結串列為空
        printf("連結串列為空!!!!");
        return;
    }

    if (pList->head == pList->tail) {
        // 連結串列只有一個結點
        pList->head = pList->tail = NULL;
        pList->length -= 1;

        return;
    }

    // 需要先遍歷的到尾結點的上一個結點,然後刪除尾結點,再更新尾結點
    Node *p = pList->head;
    while (p->next != pList->tail) {
        p = p->next;
    }

    // 釋放尾結點
    free(pList->tail);
    p->next = NULL;
    pList->length -= 1;

    // 更新尾結點
    pList->tail = p;
}

// 在指定位置插入一個結點,下標從 0 開始
void insertNodeAtIndex(SingleList *pList, int index, int value)
{
    if (index >= pList->length || index < 0) {
        // 下標越界
        printf("下標不合法!!!");
        return;
    }

    // 製造一個結點,加入連結串列中去
    Node *s = (Node *)malloc(sizeof(Node));
    s->value = value;
    s->next = NULL;

    Node *p = pList->head;
    Node *q = NULL;

    for (int i = 0; i < pList->length; i ++) {
        // 找到了要插入的節點位置
        if (i == index) {
          if (i == 0) {
              // 插入到頭結點
              s->next = pList->head;
              pList->head = s;

          } else {
              s->next = p;
              q->next = s;
          }

          pList->length += 1;
          break;
        }

        q = p;
        p = p->next;

  }

}

// 在指定位置刪除一個結點
void deleteNodeAtIndex(SingleList *pList, int index)
{
    if (index >= pList->length || index < 0) {
        // 下標越界
        printf("下標不合法!!!");
        return;
    }

    Node *p = pList->head;
    Node *q = NULL;
    for (int i = 0; i < pList->length; i ++) {
        if (index == i) {
            if (i == 0) {
                // 首節點,將連結串列的首節點指向
                pList->head = p->next;
            } else {
                q->next = p->next;
            }

            free(p);
            pList->length -= 1;
            break;
        }

        // 用 q 來記錄 p 的上一個結點
        q = p;
        p = p->next;
    }
}

// 列印連結串列中所有結點值
void printList(SingleList *pList)
{
    Node *p = pList->head;
    if (p == NULL) {
        printf("連結串列為空!!!");
    }
    while (p) {
        printf("%d\n", p->value);
        p = p->next;
    }
}

// 測試程式碼 ------------------------------------------------------
SingleList *pList = creatList();
// 加入結點
printf("------加入結點\n");
appendNode(pList, 10);
appendNode(pList, 20);
appendNode(pList, 30);
appendNode(pList, 40);
appendNode(pList, 50);

printList(pList);

printf("------刪除結點\n");
removeLastNode(pList);
printList(pList);

printf("------插入新結點到頭結點位置\n");
insertNodeAtIndex(pList, 0, 100);
printList(pList);

printf("------插入新結點到尾結點位置\n");
insertNodeAtIndex(pList, 4, 200);
printList(pList);

printf("------插入新結點到中間結點位置\n");
insertNodeAtIndex(pList, 1, 300);
printList(pList);

printf("------插入新結點到中間結點位置\n");
insertNodeAtIndex(pList, 3, 500);
printList(pList);


printf("------刪除頭結點\n");
deleteNodeAtIndex(pList, 0);
printList(pList);

printf("------刪除尾結點\n");
deleteNodeAtIndex(pList, 6);
printList(pList);

printf("------刪除中間結點\n");
deleteNodeAtIndex(pList, 3);
printList(pList);

printf("------刪除中間結點\n");
deleteNodeAtIndex(pList, 2);
printList(pList);

// 釋放連結串列
freeList(pList);

// 列印日誌 ---------------------------------------------------
------加入結點
10
20
30
40
50
------刪除結點
10
20
30
40
------插入新結點到頭結點位置
100
10
20
30
40
------插入新結點到尾結點位置
100
10
20
30
200
40
------插入新結點到中間結點位置
100
300
10
20
30
200
40
------插入新結點到中間結點位置
100
300
10
500
20
30
200
40
------刪除頭結點
300
10
500
20
30
200
40
------刪除尾結點
300
10
500
20
30
200
------刪除中間結點
300
10
500
30
200
------刪除中間結點
300
10
30
200
Program ended with exit code: 0
複製程式碼

2. 迴圈連結串列

迴圈連結串列就是尾結點的next指標指向的是頭結點,這樣連結串列就構成了環,實現起來和單連結串列差不多。主要是迴圈的條件變成了 p->next != head,這裡用到了哨兵結點,關鍵程式碼如下:


// 建立連結串列,至少有一個結點
CycleList * creatList(void)
{
    CycleList *pList = (CycleList *)malloc(sizeof(CycleList));
    // 哨兵結點
    Node *head = (Node *)malloc(sizeof(Node));
    // 自己的 next 指標指向自己
    head->next = head;
    pList->head = head;

    return pList;
}

// 在指定位置插入一個結點
void insertNodeAtIndex(CycleList *pList, int index, int value)
{
    int length = listLength(pList);
    if (index < 0 || index > length) {
        printf("下標不合法!!!!");
        return;
    }
    
    // 在末尾插入
    if (index == length) {
        appendNode(pList, value);
        return;
    }
    
    // 在中間插入
    Node *p = pList->head;
    int i = 0;
    while (p->next != pList->head) {
        if (i == index) {
            // 插入結點
            Node *s = (Node *)malloc(sizeof(Node));
            
            s->value = value;
            s->next = p->next;
            p->next = s;
        }
        p = p->next;
        i++;
    }
}


// 在指定位置刪除一個結點
void deleteNodeAtIndex(CycleList *pList, int index)
{
    int length = listLength(pList);
    if (index < 0 || index >= length) {
        printf("下標不合法!!!!");
        return;
    }

    Node *p = pList->head;
    int i = 0;
    while (p->next != pList->head) {
        if (i == index) {
            if (index == length - 1) {
            		// 在末尾刪除
                Node *q = p->next;
                p->next = pList->head;
                free(q);
                return;
            } else {
                // 在中間刪除
                Node *q = p->next;
                p->next = p->next->next;
                free(q);
            }
        }
  
        p = p->next;
        i++;
    }
}
複製程式碼

3. 雙向連結串列

雙向連結串列既有前驅指標,又有後繼指標,所以可以雙向遍歷。關鍵程式碼如下:

// 指定位置插入結點
void insertNodeAtIndex(ListNode *head, int index, int value)
{
    int length = listLength(head);
    if (index < 0 || index > length) {
        printf("下標不合法!!!\n");
        return;
    }
    
    // 在末尾插入
    if (index == length) {
        appendNode(head, value);
        return;
    }
    
    // 在中間插入
    ListNode *p = head;
    int i = 0;
    while (p && p->next) {
        if (index == i) {
            // 插入結點
            ListNode *s = (ListNode *)malloc(sizeof(ListNode));
            s->value = value;
            // 新結點的前驅結點為上一個結點
            s->prev = p;
            // 新結點的下一個結點的前驅結點為新結點
            p->next->prev = s;
            // 新結點的後繼結點為p的下一個結點
            s->next = p->next;
            // p 結點的後繼結點為s
            p->next = s;
        }
        
        p = p->next;
        i++;
    }
}

// 指定位置刪除結點
void deleteNodeAtIndex(ListNode *head, int index)
{
    int length = listLength(head);
    if (index < 0 || index >= length) {
        printf("下標不合法!!!\n");
        return;
    }
    
    ListNode *p = head;
    ListNode *q = NULL;
    int i = 0;
    while (p && p->next) {
        if (index == i) {
            // 儲存要刪除的結點
            q = p->next;
            
            if (index == length - 1) {
                // 刪除最後一個結點
                // 直接讓p的next指標置空
                p->next = NULL;
            } else {
                 // 刪除中間結點
                // p 的下一個結點的下一個結點的前驅結點變成p
                p->next->next->prev = p;
                // p的下一個結點變成下下個結點
                p->next = p->next->next;
            }
           
            // 釋放要刪除的結點
            free(q);
        }
        
        p = p->next;
        i++;
    }
    
}
複製程式碼

實現兩個有序的連結串列合併為一個有序連結串列

解題思路:

這個題和合併兩個有序的陣列為一個有序陣列的思路一樣,申請第三個連結串列,長度連結串列同時遍歷,誰的結點比較小就將誰的結點插入到新連結串列中,最後短連結串列遍歷完,再將長連結串列中剩餘的結點插入到新的連結串列中去,時間複雜度只有一層迴圈遍歷是 O(n),空間複雜度額外申請了一個 ListNode 空間,來儲存新的連結串列結點,所以是 O(n)。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */


struct ListNode* mergeTwoLists(struct ListNode* l1, struct ListNode* l2){
    // 異常判斷
    if (l1 == NULL) {
        return l2;
    }
    
    if (l2 == NULL) {
        return l1;
    }
    
    struct ListNode *firsthead = l1;
    struct ListNode *secouondhead = l2;
    // 用第三個連結串列來儲存資料
    struct ListNode *l3 = malloc(sizeof(struct ListNode));
    // 臨時變數,指向新申請的結點l3
    struct ListNode *thirdhead = l3;
    
    // 同時遍歷兩個長短連結串列
    while (firsthead && secouondhead) {
        // 那個連結串列的結點值比較小就將該結點插入到新連結串列中
        if (firsthead->val <= secouondhead->val) {
            l3->next = firsthead;
            firsthead = firsthead->next;
        }else {
            l3->next = secouondhead;
            secouondhead = secouondhead->next;
        }
        l3 = l3->next;
    }
    
    if (firsthead == NULL) {
        // 還剩第二個連結串列,將第二個連結串列中的所有結點都插入到新連結串列中
        while (secouondhead) {
            l3->next = secouondhead;
            secouondhead = secouondhead->next;
            l3 = l3->next;
        }
    }
    
    if (secouondhead == NULL) {
        // 還剩第一個連結串列,將第一個連結串列中的所有結點都插入到新連結串列中
        while (firsthead) {
            l3->next = firsthead;
            firsthead = firsthead->next;
            l3 = l3->next;
        }
    }
    
    // 該結點的next指標指向的才是真正合並後的第一個結點
    return thirdhead->next;
}
複製程式碼

實現求連結串列的中間結點

快慢指標就可以實現,快指標走兩步,慢指標走一步,遍歷完整個連結串列慢指標指向的就是中間節點。預設連結串列沒有環。如果連結串列長度是偶數,中間節點取的是下中位節點。


// 求中間節點
Node *getMiddleNode(SingleList *pList)
{
    if (pList->head == NULL) {
        printf("連結串列為空!!!");
    }
    
    // 快指標,每次走兩步
    Node *fast = pList->head;
    // 慢指標,每次走一步
    Node *slow = pList->head;
    while (slow  && fast && fast->next) {
        fast = fast->next->next;
        slow = slow->next;
    }
    
    return slow;
}

// 測試程式碼 ---------------------------------
SingleList *pList = creatList();

// 加入結點
printf("------加入結點\n");
appendNode(pList, 10);
appendNode(pList, 20);
appendNode(pList, 30);
appendNode(pList, 40);
appendNode(pList, 50);

printList(pList);
    
// 中間節點
Node *mid = getMiddleNode(pList);
printf("mid.value = %d\n", mid->value);

// 列印日誌 ---------------------------------
------加入結點
10
20
30
40
50
mid.value = 30
複製程式碼

leetcode 上相關練習

1. 反轉一個單連結串列

題目地址

struct ListNode* reverseList(struct ListNode* head){
    
    if(head == NULL || head->next == NULL) return head;
    
    struct ListNode* provious = NULL;
    struct ListNode* current = head;
    while(current) {
        // 儲存當前結點的下一個結點指標
        struct ListNode* temp = current->next;
        // 將當前節點的next指標指向上一個結點
        current->next = provious;
        // 將當前節點賦值給上一個結點
        provious = current;
        // 指向下一個結點
        current = temp;
    }

    // 最後一個provious就是連結串列的頭指標
    return provious;
}
複製程式碼

2. 兩兩交換連結串列中的節點

題目地址

解題思路: 每次走兩步遍歷連結串列,每次兩兩交換都需要修改三個結點指標的指向,需要三個變數來儲存。其中 previous 用來儲存上一次的 current 指標。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode* swapPairs(struct ListNode* head) {
    struct ListNode *current = head;
    struct ListNode *previous = NULL;
    while (current && current->next) {
        // 當前指標的下一個結點
        struct ListNode *a = current->next;
        // 當前指標的下下一個結點
        struct ListNode *b = a->next;
        // 交換
        a->next = current;
        current->next = b;
        if (previous) {
            // 將上次的指標指向交換後的結點
            previous->next = a;
        } else {
            // 重新賦值給頭指標
            head = a;
        }
        
        // 儲存上一次的指標
        previous = current;
        // 每次走兩步
        current = b;
    }
    
    return head;
}
複製程式碼

3. 判斷連結串列是否有環

題目地址

解法一: 使用一個雜湊表老儲存遍歷過的結點,每次遍歷連結串列都去雜湊表中查詢,判斷當前結點是否已經存在雜湊表中,如果在雜湊表中找到,那麼就有環,如果直到連結串列遍歷結束也沒找到,就沒有環。這種的時間複雜度是 O(n),空間複雜度是 O(n)。

解法二: 使用快慢指標,遍歷連結串列,快指標每次走兩步,慢指標每次走一步,判斷快慢指標是否相遇,如果相遇則連結串列有環,如果遍歷完連結串列也沒有相遇,說明沒有環。這種解法的時間複雜度是 O(n),空間複雜度是 O(1)。

// 解法二
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
bool hasCycle(struct ListNode *head) {
    if(head == NULL || head->next == NULL) return false;
    
    // 快慢指標
    struct ListNode *fast = head;
    struct ListNode *slow = head;
    
    while(slow && fast && fast->next) {
        fast = fast->next->next;
        slow = slow->next;
        // 如果快慢指標相遇,就表示有環
        if(slow == fast) {
            return true;
        }
    }

    return false;
    
}
複製程式碼

4. 環形連結串列

題目地址

解題思路:

給定一個連結串列,返回連結串列開始入環的第一個節點。這裡運用到了一個幾何上的數學公式,快指標和慢指標走一移動直到第一次相遇在 X 結點,假設慢指標走了 N 步,快指標就走了 2N 步,假設入環的第一個節點為 Z,則會有起點到 Z 的距離會等於 X 結點到 Z 的距離,所以在快慢指標判斷連結串列有環後只需要讓快指標從頭開始一步一步走,以此同時慢指標繼續向前走,兩者就會在第一個入環節點為 Z 相遇。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode *detectCycle(struct ListNode *head) {
    if (head == NULL || head->next == NULL) {
        return NULL;
    }
    
    struct ListNode *fast = head;
    struct ListNode *slow = head;
    // 快慢指標檢測是否有環
    while(slow && fast && fast->next){
        fast = fast->next->next;
        slow = slow->next;
        if(fast == slow) {
            break;
        }
    }
    
    // 有環
    if(fast == slow) {
        // 尋找環的入環結點
        fast = head;
        while (fast != slow) {
            fast = fast->next;
            slow = slow->next;
        }
        
        return fast;
    } else {
        return NULL;
    }
}
複製程式碼

5. 每 k 個節點一組翻轉連結串列

題目地址

解題思路:

每 k 個結點一組翻轉,可以使用遞迴實現,每一組翻轉完成後,傳入下一個結點的指標,遞迴呼叫,遞迴的基線條件是剩餘的結點個數小於k,然後需要將每一組翻轉翻轉的組尾的 next 指標指向下一組的組頭。


/*
 * @lc app=leetcode.cn id=25 lang=c
 *
 * [25] k個一組翻轉連結串列
 *
 * https://leetcode-cn.com/problems/reverse-nodes-in-k-group/description/
 *
 * algorithms
 * Hard (48.65%)
 * Total Accepted:    10.3K
 * Total Submissions: 20.3K
 * Testcase Example:  '[1,2,3,4,5]\n2'
 *
 * 給出一個連結串列,每 k 個節點一組進行翻轉,並返回翻轉後的連結串列。
 * 
 * k 是一個正整數,它的值小於或等於連結串列的長度。如果節點總數不是 k 的整數倍,那麼將最後剩餘節點保持原有順序。
 * 
 * 示例 :
 * 
 * 給定這個連結串列:1->2->3->4->5
 * 
 * 當 k = 2 時,應當返回: 2->1->4->3->5
 * 
 * 當 k = 3 時,應當返回: 3->2->1->4->5
 * 
 * 說明 :
 * 
 * 
 * 你的演算法只能使用常數的額外空間。
 * 你不能只是單純的改變節點內部的值,而是需要實際的進行節點交換。
 * 
 * 
 */
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */


struct ListNode* reverseKGroup(struct ListNode* head, int k){
        // 異常判斷
    if (head == NULL || head->next == NULL || k < 2) {
        return head;
    }
    
    // 判斷連結串列中的結點是否大於等於k個,還夠不夠翻轉
    struct ListNode *temp = head;
    for (int i = 0; i < k; i++, temp = temp->next) {
        if (temp == NULL) {
            // 不夠翻轉,基線條件
            return head;
        }
    }
    
    // k個結點一組進行結點的翻轉
    struct ListNode *current = head;
    struct ListNode *previous = NULL;
    for (int i = 0; i < k; i ++) {
        struct ListNode *temp = current->next;
        current->next = previous;
        previous = current;
        current = temp;
    }
    
    // 遞迴條件,正好每組的頭結點翻轉完成後到組尾,將next指標指向下一組的組頭結點
    head->next = reverseKGroup(current, k);
    
    // 返回頭結點指標
    return previous;
}
複製程式碼

總結一些技巧

1. 理解指標的概念

指標儲存的是變數的記憶體地址,通過指標就能找到這個變數,如連結串列中經常寫的 p->next = q,意思是說 p 結點的next 指標儲存了 q 結點的記憶體地址。

2. 警惕指標丟失和記憶體洩漏

對於連結串列的插入操作,需要注意程式碼的先後順序,寫反了就會發生記憶體洩漏,刪除操作需要手動 free 結點的記憶體

// 在 a 結點和 b 結點中間插入 x 結點,p 指標指向 a 結點, 正確寫法✔️
x->next = p->next->next;
p->next = x;

// 錯誤寫法,這個時候 p->next 已經不指向 b 了,而是指向 x。❌
p->next = x;
x->next = p->next;
複製程式碼

3. 善用哨兵結點簡化問題

有時候處理頭指標和尾指標是需要特別的處理,程式碼也不統一,引入哨兵結點後,可以簡化問題,哨兵結點不儲存資料,只是 next 指標指向連結串列的實際結點,這樣,頭結點的邏輯就可以和其他結點一樣了,不用特別處理。這樣就簡化了程式碼邏輯。如連結串列的插入邏輯和刪除邏輯,不用哨兵結點的話,就需要區別對待頭結點和尾結點。引入哨兵結點的連結串列叫做帶頭連結串列,相反沒有哨兵結點的連結串列叫做不帶頭連結串列。如圖所示:

帶頭連結串列

4. 重點留意邊界條件處理

寫連結串列程式碼很容易出錯,需要考慮的邊界條件有很多,有些額外需要做特別處理,需要特別考慮如:

  1. 連結串列為空時,程式碼是否正常?
  2. 連結串列只有一個結點時,程式碼是否正常?
  3. 連結串列只包含兩個結點時,程式碼是否正常?
  4. 處理頭結點和尾結點,程式碼是否正常?

5. 畫圖輔助分析

如果空間想象能力不夠好,特別是多層迴圈或者遞迴時,畫圖輔助分析可以幫助定位每一步的變數值,指標是怎麼指向的,也可以在沒有思路的時候通過畫圖輔助分析一步一步的總結歸納出規律來,這個時候演算法思路就變清晰一些。我寫快慢指標檢測環,定位連結串列中點和兩兩翻轉連結串列程式碼時就是在筆記上一步一步推到出程式碼規律來的。

6. 多寫多練,掌握套路

很多連結串列相關的寫法其實寫多了會發現很多類似的思路,如快慢指標思路,既可以用來定位連結串列中間結點,又可以用來檢測環,複雜點的問題也一般可以分割成小問題來處理。


擴充套件閱讀

資料結構與演算法學習-連結串列上

《演算法圖解》讀書筆記—像小說一樣有趣的演算法入門書

資料結構與演算法學習-陣列

資料結構與演算法學習-複雜度分析

資料結構與演算法學習-開篇


分享個人技術學習記錄和跑步馬拉松訓練比賽、讀書筆記等內容,感興趣的朋友可以關注我的公眾號「青爭哥哥」。

青爭哥哥

相關文章