數據結構與算法學習-鏈表下

前言

上一篇文章講了鏈表相關的概念,這篇主要記錄的是和鏈表相關的算法以及一些寫好鏈表算法代碼相關的技巧node

實現單鏈表、循環鏈表、雙向鏈表,支持增刪操做

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++;
    }
    
}
複製代碼

實現兩個有序的鏈表合併爲一個有序鏈表

解題思路:bash

這個題和合併兩個有序的數組爲一個有序數組的思路同樣,申請第三個鏈表,長度鏈表同時遍歷,誰的結點比較小就將誰的結點插入到新鏈表中,最後短鏈表遍歷完,再將長鏈表中剩餘的結點插入到新的鏈表中去,時間複雜度只有一層循環遍歷是 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;
}
複製代碼

實現求鏈表的中間結點

快慢指針就能夠實現,快指針走兩步,慢指針走一步,遍歷完整個鏈表慢指針指向的就是中間節點。默認鏈表沒有環。若是鏈表長度是偶數,中間節點取的是下中位節點。app

// 求中間節點
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. 反轉一個單鏈表

題目地址post

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. 判斷鏈表是否有環

題目地址ui

解法一: 使用一個散列表老保存遍歷過的結點,每次遍歷鏈表都去散列表中查找,判斷當前結點是否已經存在散列表中,若是在散列表中找到,那麼就有環,若是直到鏈表遍歷結束也沒找到,就沒有環。這種的時間複雜度是 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. 多寫多練,掌握套路

不少鏈表相關的寫法其實寫多了會發現不少相似的思路,如快慢指針思路,既能夠用來定位鏈表中間結點,又能夠用來檢測環,複雜點的問題也通常能夠分割成小問題來處理。


擴展閱讀

數據結構與算法學習-鏈表上

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

數據結構與算法學習-數組

數據結構與算法學習-複雜度分析

數據結構與算法學習-開篇


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

青爭哥哥
相關文章
相關標籤/搜索