資料結構與演算法知識點總結(1)陣列與連結串列

LyAsano發表於2022-04-07

 

1. 動態陣列

  它的基本思路是使用如malloc/free等記憶體分配函式得到一個指向一大塊記憶體的指標,以陣列的方式引用這塊記憶體或者直接呼叫動態陣列的介面,根據其內部的實現機制自行擴充空間,動態增長並能快速地清空陣列,對資料進行排序和遍歷。

  它的資料結構定義如下:

typedef struct {
    void *data; 
    int capacity;
    int index;
    int type_size;
    int (*comp)(const void *,const void *);
} array_t;
  • data表示: 指向一塊連續記憶體的指標;type_size: 元素型別的大小(動態執行時才能確定型別)
  • capacity: 動態陣列的容量大小,最大可用空間 ; index: 動態陣列的實際大小
  • int (*comp)(const void *,const void *): 元素的大小比較函式,comp為函式指標

2. 連結串列

  鏈式儲存是最通用的儲存方式之一,它不要求邏輯上的相鄰的元素物理位置上相鄰,僅通過連結關係建立起來。連結串列解決了順序表需要大量的連續儲存空間的缺點,但連結串列附加指標域,也帶來了浪費儲存空間的缺點。

  它有多種多樣的結構,如:

  • 只含一個指標域的單連結串列、
  • 含指向前後結點兩個指標域的雙連結串列
  • 首尾相連的迴圈連結串列(單向或雙向)
  • 塊狀連結串列(chunklist)
  • 跳躍連結串列

  A 單連結串列
  對於連結串列這種結構,有時候第一個節點可能會被刪除或者在之前新增一個節點,使得頭指標指向的節點有所改變。消除這些特殊情況的方法是在連結串列的第一個節點前儲存一個永遠不會被刪除的虛擬節點,我們稱之為頭節點,頭結點的資料域可以不設任何資訊也可以記錄表長等資訊。

  頭結點的指標域指向的是真正的第一個節點,從實現中可以看到它極大地簡化了插入和刪除操作,也避免了在C中使用二級指標跟蹤記錄頭指標的變化。為了比較使用頭結點和不使用頭結點的區別,實現的單連結串列採取不使用頭結點的方法,雙向迴圈連結串列使用頭結點,加深對連結串列操作的理解。

  B 塊狀連結串列
  對於塊狀連結串列來說,它本身是一個連結串列,但連結串列儲存的每個結點是一個陣列。如果陣列有序,結合連結串列的順序遍歷(連結串列是非隨機訪問的)和有序陣列的折半查詢可以加快資料的查詢速度,在某些情況下對於特殊的插入或刪除,它的時間複雜度O(n^(1/2))
  並且相對於普通連結串列來說節省記憶體,因為它不用儲存指向每一個資料結點的指標。

  C 跳錶
  對於跳躍連結串列,它是一種隨機化的資料結構,在有序的連結串列上增加附加的前進連結,增加是以隨機化的方式進行的,所以列表的查詢可以快速跳過部分列表而得名。在實際中它的工作效能很好 ,這種隨機化平衡方案比在平衡二叉樹中用到的確定性平衡方案更容易實現,並且在平行計算中也很有用。

2.1 單連結串列

  連結串列中節點型別描述如下:

typedef struct list_node {
    void *item;
    struct list_node *next;
} list_node_t;

  對應地,單連結串列的資料結構定義如下:

typedef struct slist {
    list_node_t *head;
    int n;
    int (*comp)(const void *,const void *);
} slist_t;

  這裡的head指標既可以定義為頭指標,指向連結串列的第一個節點,即空表初始化為NULL;它也可以定義為虛擬的頭結點,分配一個節點的記憶體,它的指標域指向連結串列的實際結點。這裡先使用不帶頭結點的方法實現單連結串列的操作

2.1.1 單連結串列的插入和刪除操作

A 單連結串列的刪除操作
  如果在連結串列尾部插入,要考慮如果連結串列為空的話尾部的插入同樣需要更新頭指標,它的實現如下:

/*在單連結串列尾部新增元素*/
void slist_push_back(slist_t *l,void *item) {
    /*構造新結點*/
    list_node_t *node=new_list_node(item);

    if(l->head){
        list_node_t *cur=l->head;
        while(cur->next){
            cur=cur->next;
        }
        cur->next=node;
    } else {
        l->head=node;
    }
    l->n++; 
}

  在連結串列頭部新增元素比較簡單,實現如下:

/*在單連結串列頭部新增元素*/
void slist_push_front(slist_t *l,void *item) {
    list_node_t *node=new_list_node(item);
    node->next=l->head;
    l->head=node; //無需區分頭指標是否為空,情形一樣
    l->n++;
}

  因而如果插入的節點是連結串列的第i個位置,就需要討論插入的情形: 頭部插入、尾部插入、中間插入,這裡不給出具體實現。

B 單連結串列的刪除操作
  如果在連結串列尾部刪除元素,分兩種情形刪除: 連結串列只有一個節點時、連結串列不止一個結點。對於含有多個結點的連結串列,需要維持一個prev指標記錄尾部元素的上一個結點再進行刪除操作。實現如下:

/*在單連結串列尾部刪除元素,若存在,返回被刪除的元素鍵值,否則返回NULL*/
void *slist_pop_back(slist_t *l) {
    list_node_t *cur,*prev;
    if(l->head){
        void *res_item;
        if(l->head->next){ //不止一個結點
            prev=l->head;
            cur=l->head->next;
            while(cur->next){ 
                prev=cur;
                cur=cur->next;
            }
            prev->next=NULL;
        } else { //只有一個節點
            cur=l->head;
            l->head=NULL;
        }
        res_item=cur->item;
        free(cur);
        l->n--;

        return res_item;
    } 
    return NULL;
}

  在連結串列頭部刪除元素比較簡單,實現如下:

/*在單連結串列頭部刪除元素,若存在返回被刪除的元素鍵值,否則返回NULL*/
void *slist_pop_front(slist_t *l) {
    list_node_t *cur;
    if(l->head){
        cur=l->head;
        l->head=l->head->next;

        void *res_item=cur->item;
        free(cur);
        l->n--;
        return res_item;
    } 
    return NULL;
}

  另外一個刪除操作是:刪除單連結串列中第一個含item值的節點,它的實現和尾部刪除類似,同樣需要討論刪除情形。具體實現如下:

/*在單連結串列中找到第一個含item值的節點並刪除此節點*/
void *slist_delete(slist_t *l,void *item) {
    list_node_t *cur,*prev;
    int (*comp)(const void *,const void *);
    comp=l->comp;
    prev=NULL;
    cur=l->head;
    while(cur){
        int cmp_res=comp(item,cur->item);
        if(cmp_res==0){
            break;
        } else {
            prev=cur;
            cur=cur->next;
        }
    }
    if(cur==NULL){ //該鍵值不存在或者連結串列為空
            return NULL;
    } else {
        if(prev==NULL) //刪除的是第一個節點
            return slist_pop_front(l);
        else {
            prev->next=cur->next;
            void *res_item=cur->item;
            free(cur);
            l->n--;
            return res_item;
        }
    }
}

2.2 雙向迴圈連結串列

  雙向迴圈連結串列中的節點型別描述如下:

typedef struct dlist_node {
    void *item;
    struct dlist_node *prev;
    struct dlist_node *next;
} dlist_node_t;

  對應地,雙向迴圈連結串列的資料結構定義如下:

typedef struct {
    dlist_node_t *head;
    int n;
    int (*comp)(const void *,const void *);
} dlist_t;

/*建立一個元素節點,讓頭尾都指向自己並設元素值*/
static inline dlist_node_t *new_dlist_node(void *item){
    dlist_node_t *node=malloc(sizeof(dlist_node_t));
    node->prev=node->next=node;
    node->item=item;
    return node;
}

  在雙向迴圈連結串列的實現中,使用的head指標為虛擬的頭結點,實現方式如下:

/**
 * 為雙向迴圈連結串列分配記憶體,兩種思路:
 * 不帶頭節點,通過判斷l->head是否為NULL來刪除連結串列
 * 帶頭節點,只需判斷cur=l->head->next與l->head的是否相等(l->head==l->head->next才為連結串列空)
 * 單連結串列實現中使用了不帶頭節點的辦法(註釋說明的頭結點只是連結串列頭指標),雙向連結串列我使用帶頭節點的思路
 * 也是為了比較這兩種方法哪個適合簡化插入和刪除操作
 */
dlist_t *dlist_alloc(int (*comp)(const void *,const void *)){
    dlist_t *l=malloc(sizeof(dlist_t));
    l->head=new_dlist_node(NULL); 
    l->n=0;
    l->comp=comp;
    return l;
}

  從後面的實現可以看出它極大簡化了連結串列的插入和刪除操作。

2.2.1 迴圈雙連結串列的插入和刪除操作

  由於使用的是帶頭結點的迴圈雙連結串列,它判空的標誌是l->head==l->head->next,一定要明確,這是判斷遍歷是否結束的標記。

A 查詢迴圈雙連結串列中第i個位置的結點
  為了簡化插入和刪除操作,假設第0個位置的節點為虛擬的頭結點(很關鍵),使得插入和刪除完全統一起來實現如下:

/*查詢雙連結串列第pos個位置的節點,pos從0開始*/
dlist_node_t *dlist_find_pos(dlist_t *l,int pos){
    if(pos<0 ||pos>l->n){
        printf("Invalid position to find!\n");
        return NULL; 
    } 

    if(pos==0){
        return l->head; //頭部插入,關鍵點,使得所有插入統一化了
    }

    dlist_node_t *cur=l->head->next;
    int j=1;//計數從1開始表示
    while(cur!=l->head){ //連結串列為空的標誌
        if(j==pos){
            break;
        }
        cur=cur->next;
        j++;
    }
    return cur;
}

B 插入操作
  在雙向迴圈連結串列某位置新增元素,可插入的pos範圍: 0-l->n

  • pos為0時表示頭部插入
  • pos為l->n時表示尾部插入

  關於雙連結串列的插入方式,tmp指標要插入在cur指標後,要麼兩節點前驅後後繼同時鏈上,要麼先鏈一個方向再鏈另外一個方向,方式不同效果相同。實現的技巧就在於基於位置查詢的函式在pos=0時返回頭指標,使得插入任何位置都使用統一的程式碼。 實現如下:

void dlist_insert(dlist_t *l,void *item,int pos){
    if(pos<0|| pos>l->n){
        printf("Invalid position");
        return;
    }
    dlist_node_t *cur=dlist_find_pos(l,pos);//定位到pos位置的節點
    dlist_node_t *tmp=new_dlist_node(item); ;//插入到pos位置的新節點
    tmp->next=cur->next;
    cur->next->prev=tmp;
    tmp->prev=cur;
    cur->next=tmp;
    l->n++;
}

C 刪除操作
  刪除操作的思路是要先找到刪除位置的前驅結點,當刪除的是第一個結點時由於位置查詢的函式同樣也可以返回第0個位置的結點指標(返回頭結點),同樣使得刪除操作都可以使用一致的程式碼。實現如下:

/*在雙向迴圈連結串列中刪除pos位置節點並輸出當前值,pos從1到l->n*/
void *dlist_delete(dlist_t *l,int pos){
    if(pos<1|| pos>l->n){
        printf("Invalid position");
        return NULL;
    }
    dlist_node_t *cur=dlist_find_pos(l,pos-1);//找到刪除位置的前驅節點
    dlist_node_t *tmp=cur->next; //被刪除位置的節點

    cur->next=tmp->next;
    tmp->next->prev=cur;
    void *res_item=tmp->item;
    free(tmp);
    l->n--;
    return res_item;
}

2.3 跳躍表skiplist

  在字典的實現中,通常使用平衡二叉樹會得到較好的效能保證,例如AVL tree、Red-Black tree、Self-adjusting trees。對於除伸展樹外(單個操作是O(n)的時間複雜度)的一些平衡樹,它們的插入、刪除等操作一般有對數級別的時間複雜度。但它們的缺點是需要維護二叉樹平衡的資訊,在實現上有一定的難度,顯然資料結構的隨機化比維護平衡資訊更容易實現。

  定義跳躍表節點和跳躍表的資料結構如下:

typedef struct skiplist_node {
    void *item;
    struct skiplist_node *forward[1];
} skiplist_node_t;

typedef struct {
    skiplist_node_t *head;
    skiplist_node_t **update;
    double prob;
    int max_level;
    int level;
    int (*comp)(const void *,const void *);
    int n;
} skiplist_t;

  為了靈活性,在跳錶結點的結構定義中,把結點指向某個含有鍵值對的表項而非整數鍵

  • item: 表示結點的資料項
  • forward: 長度為1的柔性陣列,切記節點的大小包括一個陣列元素(與長度為0的陣列大小不想同)
  • 柔性陣列: 表明每個節點對應的forward陣列是變長的

  在跳錶的資料結構定義中:

  • head: 為了簡化插入和刪除操作,定義一個虛擬頭結點,它含有最大層次+1個forward前向指標
  • update陣列: 用於在插入、刪除、查詢操作中更新每個層級被查詢節點的前驅指標。它在跳錶初始化時就被建立,防止了每次在進行插入等操作時需要分配和釋放該陣列的記憶體
  • prob: 某節點被建立時出現在某層次的概率。 它的概率分佈類似於丟硬幣實驗,連續i次出現同種情形(如正面)對應i的次數的分佈。很顯然它滿足引數為p的幾何分佈,期望值為1/p
  • level: 跳錶當前的最大層次
  • comp: 比較跳錶中表項大小的函式
  • n: 當前儲存在跳錶中的元素個數

  建議我們理想中開始查詢的層次為L(N)=log(N)/log(1/p)。例如p=0.5時,處理至多含有2^16個資料的跳錶最大的層次是16,即定義中的max_level。

2.3.1 跳錶的插入、刪除、查詢操作

A 跳錶的初始化和節點層次的隨機化生成
  在初始化跳錶時需要明確幾點:

  • 跳錶的最大層次的計算公式: int max_level= -log(N)/log(prob);。例如prob=0.5,8個節點的跳錶它應該有0,1,2,3層
  • 連結串列頭結點有max_level+1個前向指標,從0開始初始化(頭結點本身含有1個level 0級別的前向指標,再加上藉助柔性陣列擴充套件的max_level個前向指標)
  • 對於某層次i的前向指標為NULL表示該層級上的虛擬連結串列為空
  • 為防止每次插入或刪除操作時要重複分配update陣列預先初始化

  它實現如下:

skiplist_t *skiplist_alloc(int capacity,double prob,int (*comp)(const void *,const void *)){
    skiplist_t *l=malloc(sizeof(skiplist_t));
    l->prob=prob;
    l->comp=comp;
    /*注gcc的數學函式定義在libm.so檔案例,需連結上數學庫,編譯時新增 -lm選項*/
    int max_level= -log(capacity)/log(prob);//這個指的是最高的層級max_level,例如8個節點的話有0,1,2,3層
    l->max_level=max_level; //例如max_level為16
    l->level=0;
    l->head=new_skiplist_node(max_level,NULL);
     
    /*更新頭結點的forward陣列為NULL*/
    for(int i=0;i<=max_level;i++){
        l->head->forward[i]=NULL;
    }

    /*為防止每次插入或刪除操作時要重複分配update陣列*/
    l->update=malloc((max_level+1)*sizeof(skiplist_node_t *));
    l->n=0;
    return l;
}

  節點層次的隨機化生成,要點有兩個:

  • 連結串列的層次為i,表示若隨機生成的level大於i則i層次以上的前向指標均指向為NULL
  • 生成的level值範圍是0-max_level,但這種隨機數的生成效果並不是最佳的,它也可能出現某些層次以上的元素完全相同

  它的實現如下:

int rand_level(double prob,int max_level){
    int level;
    int rand_mark=prob*RAND_MAX;
    for(level=0; rand()<rand_mark && level<max_level;level++) ;
    return level;
}

B 跳錶的插入和刪除操作
  插入和刪除操作的核心在於簡單的搜尋和拆分(要麼插入要麼刪除)。通過查詢鍵在每個層次所屬的位置,記錄在一個update陣列中。update[i]表示的是插入和刪除位置的最右左邊位置(個人稱之為插入或刪除位置的前驅指標)。如下圖:

資料結構與演算法知識點總結(1)陣列與連結串列

  插入操作的要點如下:

  • 找到待插入的位置(在當前元素的前向指標的鍵與元素的鍵相等或者大於的適合退出),再更新每個層次的update陣列
  • 隨機生成新節點的level
  • 調整指向,插入新節點

  刪除操作的要點如下:

  • 找到要調整位置的前驅指標
  • 自底層向高層進行節點的刪除並釋放該節點記憶體
  • 更新跳錶的level(由於某些節點的刪除可能會使部分高層次的前向指標為NULL)

  查詢操作就比較簡單,它是插入或刪除操作的第一個步驟。三個操作的實現如下:

void *skiplist_insert(skiplist_t *l,void *item){
    skiplist_node_t *cur=l->head;
    skiplist_node_t **update=l->update;
    int (*comp)(const void *,const void *);
    comp=l->comp;
    int i;
    /*查詢鍵所屬的位置*/
    for(i=l->level;i>=0;i--){
        while(cur->forward[i]!=NULL &&comp(cur->forward[i]->item,item)<0)
            cur=cur->forward[i]; //在當前層次遍歷直至前向指標為NULL或者對應的前向指標的元素大於或等於item
        update[i]=cur; //更新插入位置的前驅指標
    }
    cur=cur->forward[0];
    if(cur!=NULL&&comp(cur->item,item)==0)
        return cur->item; //鍵值已存在,直接返回原來的節點

    int level=rand_level(l->prob,l->max_level); //最大的level控制在max_level
    if(level> l->level){ //如果新生成的層數比跳錶層數大,更新下標大於i的update陣列指向為頭結點
        for(i=l->level+1;i<=level;i++){ //持續到當前生成的level上
            update[i]=l->head;
        }
        l->level=level; //更新自己的層級數
    }
    skiplist_node_t *tmp=new_skiplist_node(level,item);

    /**
     * 調整前向指標的指向,插入新結點
     * 問題就出現在這裡,注意如果生成的level級別較低,只需要在從0..level的級別進行插入,切記不能使用l->level
     * l->level和level是有不同的,除非level大於當前跳錶的level時
     */
    for(i=0;i<=level;i++){ 
        tmp->forward[i]=update[i]->forward[i];
        update[i]->forward[i]=tmp;
    }
    l->n++;
    return NULL;
}

/*在跳錶中進行查詢,找到返回當前元素的item否則返回NULL*/
void *skiplist_find(skiplist_t *l,void *key_item){
    /*查詢是否含有當前的元素*/
    skiplist_node_t *cur=l->head;
    skiplist_node_t **update=l->update;
    int (*comp)(const void *,const void *);
    comp=l->comp;
    int i,res;
    for(i=l->level;i>=0;i--){
        while(cur->forward[i]!=NULL &&((res=comp(cur->forward[i]->item,key_item))<0))
            cur=cur->forward[i]; //在當前層次遍歷直至前向指標為NULL或者對應的前向指標的元素大於或等於item
        update[i]=cur; //更新插入位置的前驅指標
    }
    cur=cur->forward[0];
    if(cur!=NULL&&comp(cur->item,key_item)==0){
        return cur->item;
    }
    return NULL;
}


void *skiplist_delete(skiplist_t *l,void *item){
    skiplist_node_t *cur=l->head;
    skiplist_node_t **update=l->update;
    int (*comp)(const void *,const void *);
    comp=l->comp;
    int i;
    int level=l->level;
    for(i=level;i>=0;i--){
        while(cur->forward[i]&&comp(cur->forward[i]->item,item)<0)
            cur=cur->forward[i];
        update[i]=cur;
    }
    cur=cur->forward[0];
    if(cur==NULL||comp(cur->item,item)!=0) return NULL; //鍵值不存在


    for(i=0;i<=level;i++){
        if(update[i]->forward[i]!=cur) break; //若低層次的前向指標不包括cur,則高層次就不可能存在(高層次的連結串列是低層次的子連結串列)
        update[i]->forward[i]=cur->forward[i];
    }
    void *ret_item=cur->item;
    l->n--;
    free(cur);

    while(l->level>0 &&l->head->forward[l->level]==NULL)
        l->level--;
    return ret_item;
}

2.3.2 總結

  儘管跳錶在wort-case時會生成一個糟糕的不平衡結構,沒法和平衡樹一樣保證較好的最壞或均攤的效能,但發生這個情形的概率很小。並且它在實際工作中效果很好,對於很多應用來說,隨機化的平衡方法-跳躍連結串列相比平衡樹樹而言,它是一種更自然的表示,並且演算法更為簡單,實現起來更為容易,比平衡樹具有更好的常數優化效能。

  下面是一些使用跳錶的應用和框架列表,可見相比平衡樹,跳躍表還是有很多實際應用的

  • Lucene: 使用跳錶在對數時間內search delta-encoded posting lists
  • Redis: 基於跳錶實現它的有序集合
  • nessDB: a very fast key-value embedded Database Storage Engine (Using log-structured-merge (LSM) trees), uses skip lists for its memtable
  • skipdb: 一個開源的基於跳躍表實現的可移植的支援ACID事務操作的Berkeley DB分割的資料庫
  • ConcurrentSkipListSet and ConcurrentSkipListMap in the Java 1.6 API.
  • leveldb: a fast key-value storage library written at Google that provides an ordered mapping from string keys to string values
  • Skip lists are used for efficient statistical computations of running medians (also known as moving medians)。

  另外跳躍表也可應用在分散式應用中,用來實現高擴充套件性的併發優先順序佇列和併發詞典(使用少量的鎖或者基於無鎖),所以學習基於隨機化技術的跳躍表是很有必要的。

相關文章