[資料結構] 使用 C 語言:實作鏈結串列 (Linked List)

    鏈結串列的抽象資料結構

    以下是鏈結串列的抽象資料結構 (ADT):

    L is a list
    
    sub IsEmpty(L): bool
    sub PeekFront(L): result
    sub PeekRear(L): result
    sub At(L, index): result, 0 <= index < size
    sub SetAt(L, index, value): bool, 0 <= index < size
    sub Unshift(L, value): bool
    sub Push(L, value): bool
    sub Shift(L): result
    sub Pop(L): result
    sub InsertAt(L, index, value): bool
    sub RemoveAt(L, index): (result, bool)
    

    由此可知,串列有以下和狀態相關的行為:

    • IsEmpty(L):確認串列是否為空
    • PeekFront(L):檢視串列頭端的元素
    • PeekRear(L):檢視串列尾端的元素
    • At(L, index):檢視串列任意位置的元素
    • SetAt(L, index, value):設置串列任意位置的元素

    除此之外,串列可透過以下方式來操作:

    • Unshift(L, value):從串列頭端放入元素
    • Push(L, value):從串列尾端放入元素
    • Shift(L):從串列頭端移出元素
    • Pop(L):從串列尾端移出元素
    • InsertAt(L, index, value):從串列任意位置放入元素
    • RemoveAt(L, index):從串列任意位置移出元素

    鏈結串列一部分的操作和佇列或雙向佇列重疊,讀者若不熟悉這些部分可再回頭閱讀。

    宣告鏈結串列的類別

    鏈結串列的類別宣告如下:

    class Node:
        data <- empty
        prev <- null
        next <- null
        
    class List:
        head <- null
        tail <- null
        size <- 0
    

    如同我們先前實作佇列般,串列分為內部節點 Node 和主類別 List。等效的 C 語言宣告如下:

    typedef struct node node_t;
    
    struct node {
        int data;
        node_t *prev;
        node_t *next;
    };
    
    typedef struct list list_t;
    
    struct list {
        node_t *head;
        node_t *tail;
        size_t size;
    };

    鏈結串列的建構函式

    以下是 C 語言版本的串列建構函式:

    list_t * list_new(void)
    {
        list_t *lt = malloc(sizeof(list_t));
        if (!lt) {
            return lt;
        }
    
        lt->head = NULL;
        lt->tail = NULL;
        lt->size = 0;
    
        return lt;
    }

    上述建構函式會建立一個空串列,這是傳統的建構函式的寫法。也可以在初始化時塞入一些值,使用方式如下:

    list_t *lt = list_init(4, 6, 8, 5, 7);

    list_init 建構函式中,第一個參數代表串列長度,第二個以後的參數代表實際填入的值。以下是其 C 語言實作:

    list_t * list_init(size_t size, int value, ...)  /*  1 */
    {                                                /*  2 */
        list_t *lt = malloc(sizeof(list_t));         /*  3 */
        if (!lt)                                     /*  4 */
            return lt;                               /*  5 */
    
        node_t *first = node_new(value);             /*  6 */
        if (!first) {                                /*  7 */
            list_delete(lt);                         /*  8 */
            lt = NULL;                               /*  9 */
            return lt;                               /* 10 */
        }                                            /* 11 */
        lt->head = first;                            /* 12 */
        lt->tail = first;                            /* 13 */
        lt->size = 0;                                /* 14 */
    
        va_list args;                                /* 15 */
        va_start(args, value);                       /* 16 */
        lt->size++;                                  /* 17 */
    
        node_t *temp;                                /* 18 */
        for (size_t i = 1; i < size; i++) {          /* 19 */
            temp = node_new(va_arg(args, int));      /* 20 */
            if (temp == NULL) {                      /* 21 */
                list_delete(lt);                     /* 22 */
                lt = NULL;                           /* 23 */
                va_end(args);                        /* 24 */
                return lt;                           /* 25 */
            }                                        /* 26 */
    
            lt->tail->next = temp;                   /* 27 */
            temp->prev = lt->tail;                   /* 28 */
            lt->tail = temp;                         /* 29 */
    
            lt->size++;                              /* 30 */
        }                                            /* 31 */
    
        va_end(args);                                /* 32 */
    
        return lt;                                   /* 33 */
    }                                                /* 34 */

    在第 3 行至第 14 行,和先前的建構函式中建立 list_t 型態的物件 lt 的方式雷同,故不重覆說明。

    在第 15 行至第 17 行,我們將不定參數物件 args 初始化,並填入第一個參數 value

    在第 18 行至第 31 行,我們依序填入其他的參數。每個參數在填入時,都要建立新的內部節點。我們在第 20 行建立內部節點,並在第 21 行至第 26 行檢查該節點是否成功地建立。若建立失敗,則清除 lt 物件及 args 物件,並回傳空指標。

    在第 32 行,將 args 物件清除。

    最後,在第 33 行,回傳 lt 物件。

    在此建構函式中,我們使用到不定參數相關的一些巨集,這些巨集由 stdarg.h 函式庫所提供。使用時會依序使用以下四個巨集:

    • va_list:建立代表參數串列的變數
    • va_start:設置參數串列起始位置
    • va_arg:從參數串列取值
    • va_end:結束此參數串列

    讀者可參考本建構函式來學習如何使用不定參數。

    鏈結串列的解構函式

    以下是以 C 語言實作的解構函式:

    void list_delete(void *self)                 /*  1 */
    {                                            /*  2 */
        if (!self) {                             /*  3 */
            return;                              /*  4 */
        }                                        /*  5 */
    
        node_t *curr = ((list_t *) self)->head;  /*  6 */
        while (curr) {                           /*  7 */
            node_t *temp = curr;                 /*  8 */
            curr = curr->next;                   /*  9 */
            free(temp);                          /* 10 */
        }                                        /* 11 */
    
        free(self);                              /* 12 */
    }                                            /* 13 */

    同樣的,要先逐一釋放內部節點後,再釋放掉物件本身。我們在第 6 行至第 11 行中,逐一走訪內部節點,邊走訪邊釋放其記憶體。最後在第 12 行中,釋放 self 物件本身。

    檢查鏈結串列是否為空

    以下是檢查串列是否為空的虛擬碼:

    sub IsEmpty(L):
        check whether L->head == null
    

    檢查其頭端 head 是否為空即可。以下是等效的 C 語言實作:

    bool list_is_empty(const list_t *self)
    {
        assert(self);
    
        return self->head == NULL;
    }

    檢視鏈結串列頭端的資料

    以下是檢視串列頭端資料的虛擬碼:

    sub PeekFront(L):
        assert not IsEmpty(L)
        
        return L->head->data
    

    先確認串列不為空,再回傳頭端的資料 data 即可。以下是等效的 C 語言實作:

    int list_peek_front(const list_t *self)
    {
        assert(!list_is_empty(self));
    
        return self->head->data;
    }

    檢視鏈結串列尾端的資料

    和檢視頭端的情境類似,先確認串列不為空,再回傳其尾端的資料 data 即可。以下是等效的 C 語言實作:

    int list_peek_rear(const list_t *self)
    {
        assert(!list_is_empty(self));
    
        return self->tail->data;
    }

    檢視鏈結串列中任意位置的資料

    以下是檢視串列任意元置元素的虛擬碼:

    sub At(L, index):
        assert not IsEmpty(L)
        assert 0 <= index < Size(L)
        
        p <- L->head
        i <- 0
        while p != null do
            if i == index then
                return p->data
            end if
            
            p <- p->next
            i <- i + 1
        end while
        
        throw Error("No available data")
    

    要先確認串列不為空以及索引值是合法的,接著再進行下一步動作。由於本範例內部以鏈結串列實作,需逐一走訪串列元素,直到符合索引值所在的位置為止。以下是等效的 C 語言實作:

    bool list_at(const list_t *self, size_t index, int *out)  /*  1 */
    {                                                         /*  2 */
        assert(index < list_size(self));                      /*  3 */
    
        node_t* curr = self->head;                            /*  4 */
        size_t i = 0;                                         /*  5 */
        while (curr) {                                        /*  6 */
            if (i == index) {                                 /*  7 */
                *out = curr->data;                            /*  8 */
                return true;                                  /*  9 */
            }                                                 /* 10 */
    
            curr = curr->next;                                /* 11 */
            i++;                                              /* 12 */
        }                                                     /* 13 */
    
        return false;                                         /* 14 */
    }                                                         /* 15 */

    由於 C 語言沒有錯誤處理的機制且僅能回傳單一值,所以我們在這裡修改一下該函式的介面。注意第 1 行的函式界面,除了回傳函式狀態的布林值外,我們另外用指向整數的指標 out 回傳資料。此函式會回傳執行成功與否的狀態,並將回傳值寫在 out 中。

    由於串列無法像陣列般直接用索引值存取,我們我們在第 6 行至第 13 行間,以迴圈逐一走訪內部節點。走到索引值所在的地方,將資料傳至 out,回傳的動件在第 8 行。

    使用實例如下:

    /* Allocate some memory for `out`. */
    int *out = malloc(sizeof(int));
    
    if (!list_at(lt, 2, out)) {
        /* `out` is invalid. */
        /* Handle the error. */
    }
    
    /* Free `out` later. */

    修改鏈結串列任意位置的資料

    以下虛擬碼展示如何修改串列中任意位置的資料:

    sub SetAt(L, index, value):
        assert L != null
        assert 0 <= index < L->size
        
        p <- L->head
        i <- 0
        while p != null do
            if i == index then
                p->data <- value
            end if
            
            p <- p->next
            i <- i + 1
        end while
    

    同樣地,要先確認串列不為空且索引值合法。接著,逐一走訪串列直到符合索引所在的位置,修改元素的值後結束程式。以下是等效的 C 語言實作:

    bool list_set_at(list_t *self, size_t index, int data)
    {
        assert(self);
        assert(index < list_size(self));
    
        node_t* curr = self->head;
        size_t i = 0;
        while (curr) {
            if (i == index) {
                curr->data = data;
                return true;
            }
    
            curr = curr->next;
            i++;
        }
    
        return false;
    }

    同樣地,由於 C 語言沒有錯誤處理的機制,此函式會回傳程式執行成功與否的狀態。

    從鏈結串列尾端放入一個元素

    以下是從鏈結串列尾端放入元素的虛擬碼:

    sub Push(L, data): void
        node <- node_t(data)
        
        if L.size == 0 then
            L.head <- node
            L.tail <- node
        else
            L.tail.next <- node
            node.prev <- L.tail
            L.tail <- node
        end if
    
        L.size += 1
    

    根據串列本身是否為空的處理方式略為不同。以下是等效的 C 程式實作:

    bool list_push(list_t *self, int value)  /*  1 */
    {                                        /*  2 */
        assert(self);                        /*  3 */
    
        node_t *node = node_new(value);      /*  4 */
        if (!node) {                         /*  5 */
            return false;                    /*  6 */
        }                                    /*  7 */
    
        if (!(self->tail)) {                 /*  8 */
            self->head = node;               /*  9 */
            self->tail = node;               /* 10 */
        }                                    /* 11 */
        else {                               /* 12 */
            self->tail->next = node;         /* 13 */
            node->prev = self->tail;         /* 14 */
            self->tail = node;               /* 15 */
        }                                    /* 16 */
    
        self->size++;                        /* 17 */
    
        return true;                         /* 18 */
    }                                        /* 19 */

    在推入資料時,要考慮串列本身是否為空。

    在第 8 行至第 11 行間,是串列為空的情境,這時候串列的頭端和尾端都會指向同一個節點。

    在第 12 行至第 16 行間,是串列不為空的情境,這裡候將新的節點推入串列的尾端即可。

    從鏈結串列頭端放入一個元素

    以下是從鏈結串列頭端放入元素的虛擬碼:

    sub Unshift(L, value): void
        node <- node_t(value)
        
        if L.head == null then
            L.head <- node
            L.tail <- node
        else
            node.next <- L.head
            L.head.prev <- node
            L.head <- node
        end if
    
        L.size += 1
    

    根據串列本身是否為空的處理方式略為不同。以下是等效的 C 程式實作:

    bool list_unshift(list_t *self, int value)  /*  1 */
    {                                           /*  2 */
        assert(self);                           /*  3 */
    
        node_t *node = node_new(value);         /*  4 */
        if (!node) {                            /*  5 */
            return false;                       /*  6 */
        }                                       /*  7 */
    
        if (!(self->head)) {                    /*  8 */
            self->head = node;                  /*  9 */
            self->tail = node;                  /* 10 */
        }                                       /* 11 */
        else {                                  /* 12 */
            node->next = self->head;            /* 13 */
            self->head->prev = node;            /* 14 */
            self->head = node;                  /* 15 */
        }                                       /* 16 */
    
        self->size++;                           /* 17 */
    
        return true;                            /* 18 */
    }                                           /* 19 */

    在推入資料時,要考慮串列本身是否為空。

    在第 8 行至第 11 行間,是串列為空的情境,這時候頭端和尾端都會指向新節點。

    在第 12 行至第 16 行間,是串列不為空的情境,這時候會將新節點推入串列的頭端。

    本節的實作和前一節類似,讀者可相互比較一下。

    從鏈結串列尾端取出一個元素

    以下是從鏈結串列尾端放入元素的虛擬碼:

    sub Pop(L): result
        assert not IsEmpty(L)
        
        result <- L.tail.data
        
        if L.head == L.tail then
            delete L.tail
    
            L.head <- null
            L.tail <- null
        else
            p <- L.tail
            L.tail <- p.prev
            delete p
            L.tail.next <- null
        end if
        
        L.size -= 1
        
        return result
    

    根據串列本身的元素為單個或多個,處理方式相異。以下是等效的 C 程式實作:

    int list_pop(list_t *self)           /*  1 */
    {                                    /*  2 */
        assert(!list_is_empty(self));    /*  3 */
    
        int popped = self->tail->data;   /*  4 */
    
        if (self->head == self->tail) {  /*  5 */
            free(self->tail);            /*  6 */
            self->head = NULL;           /*  7 */
            self->tail = NULL;           /*  8 */
        }                                /*  9 */
        else {                           /* 10 */
            node_t *curr = self->tail;   /* 11 */
            self->tail = curr->prev;     /* 12 */
            free(curr);                  /* 13 */
            self->tail->next = NULL;     /* 14 */
        }                                /* 15 */
    
        self->size--;                    /* 16 */
    
        return popped;                   /* 17 */
    }                                    /* 18 */

    在移出資料時,有三個情境:

    • 串列為空
    • 串列僅存一個節點
    • 串列還有多個節點

    串列為空時,沒有資料可推出,這是不應當發生的情境,所以我們在第 3 行用斷言確認串列不為鑋。

    由於推出的節點皆在尾端,我們一律在第 4 行先將推出的資料預存好。

    第 5 行至第 9 行是串列僅單一節點的情境,這時候將僅存的節點釋放掉。

    第 10 行至第 15 行則是串列還存在多個節點的情境,這時候將尾端的指標前移一個節點,然後釋放原本尾端的節點。

    最後,在第 17 行回傳推出的資料。

    從鏈結串列頭端取出一個元素

    以下是從串列頭端取出元素的虛擬碼:

    sub Shift(L): result
        assert not IsEmpty(L)
        
        result <- L.head
        
        if L.head == L.tail then
            delete L.head
            
            L.head <- null
            L.tail <- null        
        else
            p <- L.head
            L.head <- p.next
            delete p
            L.head.prev <- null
        end
    
        L.size -= 1
        
        return result
    

    根據串列元素為單個或多個,處理方式有所不同。以下是等效的 C 程式實作:

    int list_shift(list_t *self)
    {
        assert(!list_is_empty(self));
    
        int popped = self->head->data;
    
        if (self->head == self->tail) {
            free(self->head);
            self->head = NULL;
            self->tail = NULL;
        }
        else {
            node_t *curr = self->head;
            self->head = curr->next;
            free(curr);
            self->head->prev = NULL;
        }
    
        self->size--;
    
        return popped;
    }

    從鏈結串列中任意位置放入元素

    以下是從串列中任意位置放入元素的虛擬碼:

    sub InsertAt(L, index, data): void
        if L.size == 0 then
            assert index == 0
        else
            assert 0 <= index <= L.size
        end
        
        node <- node_t(data)
        
        if L.size == 0 then
            L.head <- node
            L.tail <- node
            L.size += 1
            return
        end if
        
        p <- null
        q <- L.head
        i <- 0
        while q.next != null do
            if i == index then
                if p == null then
                    node.next <- q
                    q.prev <- node
                    q <- node
                else
                    p.next <- node
                    q.prev <- node
                    
                    node.prev <- p
                    node.next <- q
                end if
    
                break
            end if
            
            p <- q
            q <- q.next
            i += 1
        end while
        
        if q == L.tail then
            L.tail.next <- node
            node.prev <- L.tail
            L.tail <- node
        end
        
        L.size += 1
    

    當串列為空時,直接放入元素即可。

    走訪串列的方式是以兩個指標來走訪,一個指標指向目前的元素,一個則指向前一個元素。當串列不為空時,可再細分為三個情境:

    • 放在串列頭端
    • 放在串列尾端
    • 放在串列其他位置

    當放在串列頭 (或尾) 端時,會影響串列頭 (或尾) 端指標所指向的元素,故需獨立處理。以下是等效的 C 語言程式碼:

    bool list_insert_at(list_t *self, size_t index, int value)
    {
        assert(self);
    
        if (!(self->head)) {
            assert(index == 0);
        } else {
            assert(0 <= index && index <= self->size);
        }
    
        node_t *node = node_new(value);
        if (!node) {
            return false;
        }
    
        if (!(self->head)) {
            self->head = node;
            self->tail = node;
            self->size++;
            return true;
        }
    
        node_t *p = NULL;
        node_t *q = self->head;
        size_t i = 0;
        while(q->next) {
            if (i == index) {
                if (!p) {
                    node->next = q;
                    q->prev = node;
                    q = node;
                    self->head = q;
                } else {
                    p->next = node;
                    node->prev = p;
    
                    q->prev = node;
                    node->next = q;
                }
    
                break;
            }
    
            p = q;
            q = q->next;
            i++;
        }
    
        if (q == self->tail) {
            q->next = node;
            node->prev = q;
            q = node;
            self->tail = q;
        }
    
        self->size++;
    
        return true;
    }

    從鏈結串列中任意位置取出元素

    在從串列中移出元素時,需考慮以下情境:

    • 串列為空
    • 串列僅單個元素
    • 串列有多個元素

    在本例中,第一個情形直接以錯誤排除,第三個情形需再細分,下文會說明。以下是虛擬碼:

    sub RemoveAt(L, index): result
        assert not IsEmpty(L)
    
        if L.size == 1 then
            assert index == 0
        else
            assert 0 <= index < L.size
        end if
        
        if L.size == 1 then
            result <- L.head.data
            
            delete L.head
            L.head <- null
            L.tail <- null
            L.size -= 1
            
            return result
        end if
        
        p <- null
        q <- L.head
        i <- 0
        while q.next != null do
            if i == index then
                result <- q.data
                
                if p == null then
                    L.head <- q.next
                    delete q
                    L.head.prev <- null
                else
                    p.next <- q.next
                    q.next.prev <- p
                    delete q
                end if
    
                break
            end if
            
            p <- q
            q <- q.next
            i += 1
        end while
        
        if q == L.tail then
            result <- q.data
            L.tail <- q.prev
            delete q
            L.tail.next <- null
        end if
        
        L.size -= 1
        
        return result
    

    當串列僅單個元素時,直接移出該元素即可。

    走訪串列的方式是以兩個指標來走訪,一個指標指向目前的元素,一個則指向前一個元素。當串列有多個元素時,需再區分:

    • 從串列頭端移出元素
    • 從串列尾端移出元素
    • 從串列其他位置移出元素

    當從串列頭 (或尾) 端移出串列時,會影響頭 (或尾) 端指標所指向的元素,故需獨立出來處理。

    int list_remove_at(list_t *self, size_t index)
    {
        assert(!list_is_empty(self));
    
        if (list_size(self) == 1) {
            assert(index == 0);
        }
        else {
            assert(index < list_size(self));
        }
    
        if (list_size(self) == 1) {
            int result = self->head->data;
    
            free(self->head);
            self->head = NULL;
            self->tail = NULL;
            self->size--;
    
            return result;
        }
    
        int result;
    
        node_t *p = NULL;
        node_t *q = self->head;
        size_t i = 0;
        while (q->next) {
            if (i == index) {
                result = q->data;
    
                if (!p) {
                    self->head = q->next;
                    free(q);
                    self->head->prev = NULL;
                }
                else {
                    p->next = q->next;
                    q->next->prev = p;
                    free(q);
                }
    
                break;
            }
    
            p = q;
            q = q->next;
            i++;
        }
    
        if (q == self->tail) {
            result = q->data;
            self->tail = p;
            free(q);
            self->tail->next = NULL;
        }
    
        self->size--;
    
        return result;
    }

    在演算法上的效率

    以下和狀態相關的行為的演算法效率如下:

    • IsEmpty(L)O(1)
    • PeekFront(L)O(1)
    • PeekRear(L)O(1)
    • At(L, index)O(n)
    • SetAt(L, index, value)O(n)

    由此可見,以索引操作串列的效率會比陣列差,如果串列大小不會更動或更動不頻繁的話,或許可考慮改用陣列。

    以下操作串列的方法的演算法效率如下:

    • Unshift(L, value)O(1)
    • Push(L, value)O(1)
    • Shift(L)O(1)
    • Pop(L)O(1)
    • InsertAt(L, index, value)O(n)
    • RemoveAt(L, index)O(n)

    由此可見若串列大小會頻繁更動時,串列的效率會比陣列好。

    【分享本文】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email
    【追蹤本站】
    Facebook Facebook Twitter Plurk