哈希表 (桶)

之前博文提到的平衡二叉搜索樹 (AVL 樹 和 紅黑樹)可以在 O(logN) 的時間複雜度內進行增刪等操作,下面要介紹的哈希表 (hashtable) 在增刪等操作表現更爲出色,時間複雜度爲 O(1)。

概述

不同於之前的樹形數據結構,此處的哈希表是一種表 (或者字典) 結構,這種結構的好處是提供常數時間複雜度的基本操作,就像數組那樣,想要訪問某個元素,通過下標一步就可以找到該元素。

事實上,哈希表就是藉助數組下標定位的這種方法來達到常數時間複雜度的操作的。

看個例子:
假如此處有一組數據,其類型皆爲 unsigned char (即範圍爲 0 ~ 255),那麼通過一個大小爲 256 的數組A 儲存 這組元素,數組的 256 個位置上初始值皆爲 0, 當插入 i 時, 就執行 A[i]++, 當刪除 j 時, 就執行 a[j]--,要查找 k ,則判斷 0 == a[k],以上的操作時間複雜度都爲 O(1)。
這裏寫圖片描述

很顯然,這種方法存在幾個缺陷。

  • 如果此處只有 20 個 unsigned char 類型的數據, 那麼我們還是需要一個大小爲 256 的數組,空間浪費嚴重
  • 如果數據類型爲 32bit 位的 int,那麼這個儲存數據的數組就需要 4GB,這顯然大的不切實際
  • 如果數據是字符串或者其他複雜類型,就無法拿來當數組索引。

我們可以通過某種映射函數來解決上述問題,將大數據轉換爲我們可以接受的小數據,這個映射函數我們稱之爲 哈希函數
常見的哈希函數的構造方法有以下幾種:數字分析法,平方取中法,分段疊加法,僞隨機數法,除留餘數法。通常採用除留餘數法。
使用哈希函數存在一個問題:不同的值會被映射到相同位置。這種現象叫做哈希碰撞(或者哈希衝突)。我們可以通過下面的方法解決哈希碰撞。

哈希碰撞

  • 負載因子:元素個數除以表格大小。對於線性探測和二次探測的負載因子一般爲 0~0.7 ,不讓數據過於飽滿而造成嚴重的哈希碰撞。對於開鏈法負載因子則可以設置爲 0 ~ 1。在每次插入元素時,都會檢查負載因子,當負載因子超過限定值時,就重新分配空間,並且重新哈希(將數據搬移到新表中)。

下面介紹解決哈希碰撞的方法(線性探測、二次探測、開鏈法)。

  • 線性探測
    線性探測是指:當計算出的某個位置已經被佔用,我們就依序往後尋找,到達尾部時就跳到頭部繼續,這樣總能找到一個安身立命的位置。對與元素刪除,只能採用“惰性刪除”—— 只用符號標記刪除,而不真正抹除。元素的查找就很簡單了,在哈希函數計算出來的位置上找,若沒有則依序往後尋找,直到碰到一個“空”位置(指的是沒有插入過數據的位置,如果數據插入後又刪除, 則還得往後找)。

下面是依次插入五個數據時數組的變化。

這裏寫圖片描述

上述方法存在很大的一個缺陷是:當接下來插入8,9,0,1,2中的任意一個數據時,它們都會產生哈希碰撞而不會落在相應的位置,都需要向後查找,這樣就與我們之前 O(1) 的時間複雜度背道而馳了。

  • 二次探測

二次探測是爲了解決一次探測中連續的哈希碰撞的問題。

如果計算出來的位置已被佔用,則就一次找這些位置 H+1^2, H+2^2, H+3^2 … H+i^2,而不是挨個找。
下面是依次插入五個數時數組的變化。
這裏寫圖片描述

可以看到,這種方法處理使得每次探測的都是一個不同的位置。我們如果設置表格大小爲質數, 而且保持負載係數在 0.5 之下(當超過 0.5 就重新分配空間並搬移元素),那麼可以保證每次插入探測的次數不大於2。

上面的兩種方法都是基於:將數據直接儲存在數組中,即數組的每個位置只儲存一個元素,下面介紹一種方法——開鏈法。數組的每個位置“掛”着一個桶,桶裏可以有多個元素。

開鏈法與哈希桶

在 STL 中採用開鏈法處理哈希衝突。

這裏寫圖片描述

數組的每個位置儲存的都是一個“桶”(鏈表),衝突的元素被鏈接到同一個鏈表後面,這種方法稱爲開鏈法。

下面是 STL 中哈希桶的結構。

//桶中鏈接的節點
template <class Value>
struct __hashtable_node
{
  __hashtable_node* next;//指向下一個衝突節點的指針
  Value val;//值
};

template <class Value, class Key, class HashFcn,
          class ExtractKey, class EqualKey,
          class Alloc>
class hashtable {

  //...

private:
  //...

  vector<node*,Alloc> buckets;//以擁有動態擴容的vector管理桶子

  //...
};

模板參數

  • Value:節點的實值類型;
  • Key:節點關鍵在類型;
  • HashFcn:仿函數,哈希函數;
  • ExtractKey: 仿函數,從節點中取出關鍵字的方法;
  • EqualKey: 仿函數,關鍵字的比較方法;
  • Alloc: 空間配置器。

哈希函數

對於簡單的類型(int、char、 long、size_t等),採用除留餘數法就可以求出哈希值,而對於複雜類型,比如 string, 採用除留餘數法就難以下手。

問題:

  • 採用除留餘數法的話應該除以多少?
  • 複雜類型應該如何處理?

對於第一個問題:通過數學方法研究發現,當模數爲質數時,產生哈希碰撞的機率會大大下降,故我們將表的大小設置爲質數,每次增容時也將其增加到一個質數大小的值。這些質數由下表給出:

static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
  53,         97,         193,       389,       769,
  1543,       3079,       6151,      12289,     24593,
  49157,      98317,      196613,    393241,    786433,
  1572869,    3145739,    6291469,   12582917,  25165843,
  50331653,   100663319,  201326611, 402653189, 805306457, 
  1610612741, 3221225473, 4294967291
};

inline unsigned long __stl_next_prime(unsigned long n)
{
  const unsigned long* first = __stl_prime_list;
  const unsigned long* last = __stl_prime_list + __stl_num_primes;
  const unsigned long* pos = lower_bound(first, last, n);
  return pos == last ? *(last - 1) : *pos;
}

第一次表的大小設置爲 53, 當後面每次增容時就去表中找比當前表大的一個質數。表中的質數從小到大大約呈2倍關係。

對於複雜類型,我們不能採用除留餘數法。比如,對於 字符串,我們就需要採用字符串哈希函數。下面列出了一些哈希函數以及它們性能的比較:

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);
}

// 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);
}

// 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);
}
//編程珠璣中的一個哈希函數
//用跟元素個數最接近的質數作爲散列表的大小
#define NHASH 29989
#define MULT 31

unsigned in hash(char *p)
{
    unsigned int h = 0;
    for (; *p; p++)
        h = MULT *h + *p;
    return h % NHASH;
}

這裏寫圖片描述

下面是 STL 中的哈希函數(定義於 stl_hash_fun.h 中),可以處理多種類型:


template <class Key> struct hash { };

//處理字符串
inline size_t __stl_hash_string(const char* s)
{
  unsigned long h = 0; 
  for ( ; *s; ++s)
    h = 5*h + *s;

  return size_t(h);
}

//特化出不同版本以處理不同類型
__STL_TEMPLATE_NULL struct hash<char*>
{
  size_t operator()(const char* s) const { return __stl_hash_string(s); }
};

__STL_TEMPLATE_NULL struct hash<const char*>
{
  size_t operator()(const char* s) const { return __stl_hash_string(s); }
};

//處理簡單內置類型,直接返回它們本身
__STL_TEMPLATE_NULL struct hash<char> {
  size_t operator()(char x) const { return x; }
};
__STL_TEMPLATE_NULL struct hash<unsigned char> {
  size_t operator()(unsigned char x) const { return x; }
};
__STL_TEMPLATE_NULL struct hash<signed char> {
  size_t operator()(unsigned char x) const { return x; }
};
__STL_TEMPLATE_NULL struct hash<short> {
  size_t operator()(short x) const { return x; }
};
__STL_TEMPLATE_NULL struct hash<unsigned short> {
  size_t operator()(unsigned short x) const { return x; }
};
__STL_TEMPLATE_NULL struct hash<int> {
  size_t operator()(int x) const { return x; }
};
__STL_TEMPLATE_NULL struct hash<unsigned int> {
  size_t operator()(unsigned int x) const { return x; }
};
__STL_TEMPLATE_NULL struct hash<long> {
  size_t operator()(long x) const { return x; }
};
__STL_TEMPLATE_NULL struct hash<unsigned long> {
  size_t operator()(unsigned long x) const { return x; }
};

哈希桶的迭代器

採用開鏈法的哈希桶的迭代器必須維持整個桶結構之間的鏈接關係,並且記錄當前所指節點。其 ++ 跳到下一個節點,如果下一個節點是當前桶(鏈表)的尾端, 那麼久跳到下一個不爲空的桶的第一個節點。

下面是 STL 中的迭代器代碼:


template <class Value, class Key, class HashFcn,
          class ExtractKey, class EqualKey, class Alloc>
struct __hashtable_iterator {
  //...
  typedef __hashtable_node<Value> node;
  //...
  node* cur;//當前所指節點
  hashtable* ht;//維持整個表結構,得以到達當前桶尾端時可以跳到下一個桶中
  //...
  //++操作細節
__hashtable_const_iterator<V, K, HF, ExK, EqK, A>::operator++()
{
  const node* old = cur;
  cur = cur->next;//跳到下個節點
  if (!cur) {//若到達尾端,則尋找下一個不爲空的桶
    size_type bucket = ht->bkt_num(old->val);
    while (!cur && ++bucket < ht->buckets.size())
      cur = ht->buckets[bucket];
  }
  return *this;
}

哈希桶無 operator-- 操作。 operator++ 操作爲了方便遍歷整個表,而 operator-- 沒有意義。

———謝謝!

參考資料

【作者:果凍 http://blog.csdn.net/jelly_9

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章