1. 程式人生 > 其它 >資料結構與演算法知識點總結(2)佇列、棧與散列表

資料結構與演算法知識點總結(2)佇列、棧與散列表

 

1. 佇列

  佇列是一種FIFO的資料結構,它有兩個出口,限定只能在表的一端進行插入(隊尾插入)和在另一端進行刪除(隊頭刪除)操作,同樣的它也沒有遍歷行為。

  由於在佇列的順序儲存中無法判斷佇列滿的條件,一般地如果用陣列實現佇列,迴圈佇列是必須的。一般設定一個隊頭指標front和隊尾指標rear,初始化兩變數均為0。為區分隊空和隊滿,一般犧牲一個單元來區分隊空和隊滿,這是種較為普遍的做法,約定以front在rear的下一個位置為隊滿標誌(front指向隊頭元素的上一個元素,rear指向隊尾元素)。如下為其重要操作:

  • 隊空標誌: front==rear ; 隊滿標誌: (rear+1)%MAXSIZE==front
  • 入隊操作: rear=(rear+1)%MAXSIZE; queue[rear]=x
  • 出隊操作: front=(front+1)%MAXSIZE; x=queue[front];

  在進行棧或佇列操作時使用記憶體複製memcpy的行為,並非原始的資料地址,如果把其應用在二叉樹的遍歷演算法中是存在bug的。

  佇列應用在在層次遍歷、計算機系統的資源請求中,它的特點就是在進行當前層的處理時就對下一層資料進行預處理。

/**
 * 迴圈佇列結構定義
 * 如果是固定長度的迴圈陣列,一般建議犧牲一個單元來區分隊空和隊滿
 * 入隊時少一個單元,因而一般設定front指向隊頭的上一個元素,
 * rear指向隊尾元素
 
*/ typedef struct { void *data; int capacity; int front; //指向隊頭元素的上一個元素 int rear; //指向隊尾元素 int type_size; } queue_t;

 

2. 棧

  棧是一種LIFO的資料結構,它只有一個出口,只允許在表的一端進行操作,如插入、刪除、取得棧頂元素,不允許有其他方法可以存取棧的其他元素(沒有遍歷行為)。

  在棧的順序儲存結構中,一般設定一個top變數指向棧頂元素的下一個位置,初始化為0。如下為其重要操作:

  • 棧為空的條件: top==0
  • 壓棧操作: stack[top++]=x
  • 出棧操作: x=stack[--top]
    /*基於動態陣列的棧結構定義*/
    typedef struct {
        void *data;
        int capacity; //允許容納最大容量
        int top; //當前棧頂的下一個位置
        int type_size; //元素型別的位元組大小
    } stack_t;

    棧在括號匹配、表示式計算(中綴表示轉字尾表示、字尾表示式計算)、進位制轉換、迷宮求解都有應用,它一般也作為遞迴演算法的非遞迴表示。

3. 散列表

  散列表,又稱雜湊表,它是根據關鍵字而進行快速存取的技術,是一種典型的“空間換取時間”的做法。它是普通陣列概念的推廣,因為可以對陣列進行直接定址,故可以在O(1)時間內訪問陣列的任意元素。

  它的思路是試圖將關鍵字通過某種規則對映到陣列的某個位置,以加快查詢的速度。這種規則稱之為雜湊函式,存放的陣列稱為散列表。散列表建立起了關鍵字和儲存地址的一種直接對映關係,特別適合於關鍵字總數很多而儲存在字典中的關鍵字集合很少的情形,儘管在最壞情況下,查詢一個元素的時間為O(n),但在實際應用中,散列表的效率還是很高的,在一些合理的假設下,散列表查詢的期望時間為O(1)。

  使用雜湊的查詢演算法分為兩步。第一步是用雜湊函式將查詢的關鍵字轉化為陣列的一個索引。理想情況下,不同關鍵字都能對映為不同的索引值,當然,實際情況是我們需要面對多個不同的關鍵通過雜湊函式得到相同的陣列下標。我們稱之為碰撞衝突。一方面,設計好的Hash函式應儘量減少這樣的衝突,另一方面由於這樣的衝突總是不可避免,所以我們要設計好處理碰撞衝突的方法。這是第二步。

3.1 雜湊函式和處理衝突的方法

  構造雜湊函式要注意以下幾點:

  • Hash函式定義域必須包含全部關鍵字,而值域依賴於散列表的大小或地址範圍
  • 理想中的Hash函式計算出來的地址應該能等概率、均勻地分佈在整個地址空間,以減少衝突的發生
  • Hash函式應該儘量簡單,能夠在較短時間內計算出任意關鍵字的儲存地址
  • 所有雜湊函式都具有一個特性:如果兩個雜湊值不想同,則兩個雜湊值的原始輸入也不相同

  A 常用的雜湊函式

  1. 直接定址法,計算簡單,適合關鍵字分佈基本連續的情況,H(key)= a*key+b
  2. 除留餘數法,最簡單最常用,關鍵是選好質數p,保證雜湊的關鍵字等概率地對映到任一地址,p是一個不大於散列表長m,但最接近或等於m的質數H(key)= key %p
  3. 數字分析法,若關鍵字是r進位制數,在某些位可能分佈均勻,應選擇數碼分佈均勻的若干位作為雜湊地址適合於一個已知的關鍵字集合
  4. 平方取中法

  B 字串Hash函式比較
  還記得Java的字串物件中hashCode的計算方法為什麼經常用31來計算麼?其實它是通過實驗優化最終確定的。它是屬於BKDRHash函式的一種,初始化種子為31。

例如一個日期型別,它對應的hashCode實現如下:

public class Date implements Comparable<Date> {
    private static final int[] DAYS={0,31,29,31,30,31,30,31,31,30,31,30,31};
    /*成員變數均為final型別,表示一經初始化就不可變化*/
    private final int month;
    private final int day;
    private final int year;

    public int hashCode() {
        int hash=17;
        hash=31*hash+month;
        hash=31*hash+day;
        hash=31*hash+year;
        return hash;    
    }

  常見的字串Hash函式有BKDRHash、APHash、DJBHash、JsHash、RSHash、SDBHash、PJWHash、ELFHash。它的結果顯示BKDRHash無論是在實際效果還是編碼實現中,效果都是最突出的,可以看到BKDRHash的種子選取是很有規律的31 131 1313 13131 etc..,非常適合記憶。

  如下附有32位無符號的Hash函式的C程式碼:

// BKDR Hash Function
unsigned int BKDRHash(char *str)
{
    unsigned int seed = 131; // 31 131 1313 13131 131313 etc..
    unsigned int hash = 0;

    while (*str)
    {
        hash = hash * seed + (*str++);
    }

    return (hash & 0x7FFFFFFF);
}

unsigned int SDBMHash(char *str)
{
    unsigned int hash = 0;

    while (*str)
    {
        // equivalent to: hash = 65599*hash + (*str++);
        hash = (*str++) + (hash << 6) + (hash << 16) - hash;
    }

    return (hash & 0x7FFFFFFF);
}

// RS Hash Function
unsigned int RSHash(char *str)
{
    unsigned int b = 378551;
    unsigned int a = 63689;
    unsigned int hash = 0;

    while (*str)
    {
        hash = hash * a + (*str++);
        a *= b;
    }

    return (hash & 0x7FFFFFFF);
}

// JS Hash Function
unsigned int JSHash(char *str)
{
    unsigned int hash = 1315423911;

    while (*str)
    {
        hash ^= ((hash << 5) + (*str++) + (hash >> 2));
    }

    return (hash & 0x7FFFFFFF);
}

// P. J. Weinberger Hash Function
unsigned int PJWHash(char *str)
{
    unsigned int BitsInUnignedInt = (unsigned int)(sizeof(unsigned int) * 8);
    unsigned int ThreeQuarters    = (unsigned int)((BitsInUnignedInt  * 3) / 4);
    unsigned int OneEighth        = (unsigned int)(BitsInUnignedInt / 8);
    unsigned int HighBits         = (unsigned int)(0xFFFFFFFF) << (BitsInUnignedInt - OneEighth);
    unsigned int hash             = 0;
    unsigned int test             = 0;

    while (*str)
    {
        hash = (hash << OneEighth) + (*str++);
        if ((test = hash & HighBits) != 0)
        {
            hash = ((hash ^ (test >> ThreeQuarters)) & (~HighBits));
        }
    }

    return (hash & 0x7FFFFFFF);
}

// ELF Hash Function
unsigned int ELFHash(char *str)
{
    unsigned int hash = 0;
    unsigned int x    = 0;

    while (*str)
    {
        hash = (hash << 4) + (*str++);
        if ((x = hash & 0xF0000000L) != 0)
        {
            hash ^= (x >> 24);
            hash &= ~x;
        }
    }

    return (hash & 0x7FFFFFFF);
}

// DJB Hash Function
unsigned int DJBHash(char *str)
{
    unsigned int hash = 5381;

    while (*str)
    {
        hash += (hash << 5) + (*str++);
    }

    return (hash & 0x7FFFFFFF);
}

// AP Hash Function
unsigned int APHash(char *str)
{
    unsigned int hash = 0;
    int i;

    for (i=0; *str; i++)
    {
        if ((i & 1) == 0)
        {
            hash ^= ((hash << 7) ^ (*str++) ^ (hash >> 3));
        }
        else
        {
            hash ^= (~((hash << 11) ^ (*str++) ^ (hash >> 5)));
        }
    }

    return (hash & 0x7FFFFFFF);
}

  C 工業界比較知名的Hash演算法
  這些演算法通常應用於資訊保安領域(MD: message digest縮寫)

  • MD4: 一種用來測試資訊完整性的密碼雜湊函式的實現。一般128位長度的MD4雜湊函式被表示為32字長的數字,並用高速軟體實現
  • MD5: 一種符合工業標準的單向128位雜湊方案。以結果唯一併且不能返回其原始格式的方法來轉換資料(如密碼)。速度相比MD4更慢,但更安全,在抗分析和抗查分表現更好
  • SHA-1: 由美國國安局設計,從一個最大的2^64位元的資訊中產生一串160元的摘要。

  一般地這些雜湊演算法在符號表或字典實現中代價很大,應用並不多,它們在資訊保安領域主要應用在檔案校驗、數字簽名、數字指紋和儲存密碼中(MD4,MD5,SHA-1已被確定不安全,SHA-2,WHIRLPOOL)

  D 處理衝突的方法
  在前言中談到了任何雜湊函式都不可避免地遇到衝突,此時必須考慮衝突發生時應該如何進行處理,即為產生的關鍵字尋找下一個空的Hash地址,於是有各種處理衝突的方法

  • 拉鍊法

  這種方法是在每個表格元素中維護一個list,把所有衝突的關鍵字儲存在同一個list中。使用開鏈法,表格的負載係數(表中記錄數n/散列表長度m)大於1,它適用於經常進行插入和刪除的情況。STL裡面的hash table便採用了這種做法

  • 開放定址法

  這種方法是指可存放新表項的空閒地址,既向它的同義詞表項開放,又向它的非同義詞表項開放。遞推公式為

     Hi=(H(key)+di)Hi=(H(key)+di)

  其中i=1,2,...,k-1,m為散列表表長,增量序列d它通常有以下幾種取法:

  1. 線性探測法,特點是衝突發生時順序查看錶中的下一個單元,直至找到一個空單元或查遍全表,缺點是容易產生聚集現象

      di=1,2,...,m1di=1,2,...,m−1 
  2. 二次探測法,表長m必須是4k+3的質數,可以很好的避免出現堆積問題,但無法探測到所有的單元 

      di=1,1,4,4,,...,k2,k2,k<=m/2di=1,−1,4,−4,,...,k2,−k2,k<=m/2 
  3. 序列為偽隨機數序列時,稱為偽隨機探測法

  4. 當發生衝突時,利用另外一個雜湊函式再次計算一個地址,直到不再發生衝突,稱為再雜湊法

  總結它有以下幾個要點:

    • 在開放定址法中,不能隨便地物理刪除表中已有元素,因為若刪除元素將會階段其他具有相同雜湊地址的元素的查詢地址。建議是採用惰性刪除,即只標記刪除記號,實際刪除操作則待表格重新整理時再進行(rehashing)。可看出它的負載係數永遠小於1,經理論分析,當負載因素為0.5時,查詢命中所需要的探測次數為3/2,未命中的需要約5/2,所以保持散列表的使用率小於1/2即可獲得一個較好的查詢效能。
    • ASL(成功): 搜尋到表中已有元素的平均探測次數,平均的概念是針對表中當前非空元素,並非整個表長;ASL(不成功): 表中可能雜湊到的位置上要插入新元素時為找到空桶的探測次數的平均值,平均的概念是針對雜湊函式對映到的位置總數(有時候存在表長與散列表質數的選取不一致的情形),一般是針對表長
    • 拉鍊法更容易實現刪除操作,如果雜湊函式設計得不好相比線性探測法對於聚集現象更不敏感;線性探測法更為節省記憶體空間,並且具有更好的Cache效能
    • 散列表的查詢效率取決於三個因素: 雜湊函式、處理衝突的方法和負載係數。

3.2 散列表的實現

  如下為基於拉鍊法的散列表節點和散列表資料結構的定義:

/*散列表結點元素的定義*/
typedef struct hash_tbl_node {
    void *item;
    struct hash_tbl_node *next;
} hash_tbl_node_t;


typedef struct {
    int num_buckets;
    int num_elements;
    hash_tbl_node_t **buckets;
    int (*hash_fcn)(const void *,int);
    int (*comp_fcn)(const void *,const void *);
} hash_tbl_t;

  引數說明如下:

  • num_elements: 散列表中元素(結點)的個數(用於動態調整表格大小,重建表格而用)
  • buckets_num: 散列表的表格大小(表中的每個元素項稱為桶),在STL中以質數來設計表格大小
  • STL中甚至提供一個函式,用於查詢在這28個作為表格大小的質數中,最接近某數並大於某數的質數
  • buckets: 由指向連結串列的結點指標構成的陣列
  • hash_fcn: 針對元素表項鍵值的雜湊函式指標
  • comp_fcn: 比較元素表項大小的函式指標

  它的測試程式碼如下:

#define NUM_ITEMS 30
#define NUM_BUCKETS 17
#define RND_MAX 1000

typedef struct {
    int key;
    int val;
} test_item_t;

int comp_fcn(const void *a,const void *b){
    return ((test_item_t *)a)->key-((test_item_t*)b)->key;
}

int hash_fcn(const void *item, int n){
    /*將鍵值作為隨機的種子*/
    // srand(((test_item_t *)item)->key);
    // rand();
    // return rand()%n;
    int key=((test_item_t *)item)->key;
    return  key% n;
}

void visit(hash_tbl_node_t *cur){
    if(cur){
        test_item_t *item=(test_item_t *)cur->item;
        printf("(%d , %d) ",item->key,item->val);
    }
}


void test(){
    test_item_t arr[NUM_ITEMS],*cur,*item;
    int i;
    for(i=0;i<NUM_ITEMS;i++){
        arr[i].key=i*i;
        arr[i].val=rand()%RND_MAX;
    }

    printf("\n========以NUM_BUCKETS大小將散列表初始化========\n");
    hash_tbl_t *tbl=hashtbl_alloc(NUM_BUCKETS,hash_fcn,comp_fcn);

    printf("\n========散列表的插入測試========\n");
    for(i=0;i<NUM_ITEMS;i++){
        item=&arr[i];
        cur=hashtbl_insert(tbl,item);
        if(cur){
            printf("Duplicate key-val pair: (%d , %d) detected, try again please !\n",cur->key,cur->val);
            i--;
        } else{
            printf("Inserted key-val pair: (%d , %d)\n",item->key,item->val);
        }
    }
    

    printf("\n========散列表的重複插入測試========\n");
    test_item_t test_dup;
    test_dup.key=2*2;
    if(hashtbl_insert(tbl,&test_dup)){
        printf("Duplicate detected\n");
    } else {
        printf("No Duplicate\n");
    }

    printf("\n========散列表的查詢測試========\n");
    test_item_t find_dup;
    for(i=0;i<NUM_ITEMS;i++){
        find_dup.key=i;
        if(hashtbl_find(tbl,&find_dup)){
            printf("key %d found \n",i);
        } else{
            printf("key %d not found \n",i);
        }
    }

    printf("\n========插入操作後散列表的遍歷========\n");
    hashtbl_foreach(tbl,visit);

    printf("\n========散列表的刪除測試========\n");
    test_item_t del_item;
    for(i=0;i<NUM_ITEMS;i++){
        del_item.key=i;
        printf("key %d: ",del_item.key);
        if(hashtbl_delete(tbl,&del_item)){
            printf("delete successfully\n");
        } else{
            printf("not exists\n");
        }
    }
    printf("\n========刪除操作後散列表的遍歷========\n");
    hashtbl_foreach(tbl,visit);

    printf("\n========釋放散列表記憶體========\n");
    hashtbl_free(tbl);
    printf("\n========釋放散列表記憶體後散列表的遍歷========\n");

}