hashmap C++實現分析及std::unordered_map拓展


今天想到哈希函數,好像解決沖突的只了解了一種鏈地址法而且也很模糊,就查了些資料復習一下

1、哈希
Hash 就是把任意長度的輸入,通過哈希算法,變換成固定長度的輸出(通常是整型),該輸出就是哈希值。

這種轉換是一種壓縮映射,也就是說,散列值的空間通常遠小於輸入的空間。不同的輸入可能會散列成相同的輸出,因此不能從散列值來唯一地確定輸入值。

簡單的說,哈希就是一種將任意長度的消息壓縮到某一固定長度的信息摘要函數。

2、哈希表
2.1哈希表有多種不同的實現,其核心問題是如何解決沖突,即不同輸入產生相同輸出時,應該如何存儲。

最經典的一種實現方法就是拉鏈法,它的數據結構是鏈表的數組:

 

數組的特點是:尋址容易,插入和刪除困難。

鏈表的特點是:尋址困難,插入和刪除容易。

對於某個元素,我們通過哈希算法,根據元素特征計算元素在數組中的下標,從而將元素分配、插入到不同的鏈表中去。在查找時,我們同樣通過元素特征找到正確的鏈表,再從鏈表中找出正確的元素。
2.2 還有種方法是開放地址法,用開放地址處理沖突就是當沖突發生時,形成一個地址序列,沿着這個序列逐個深測,直到找到一個“空”的開放地址,將發生沖突的關鍵字值存放到該地址中去。這里就不詳細介紹了

3、HashMap 的數據結構             
HashMap 實際上就是一個鏈表的數組,對於每個 key-value對元素,根據其key的哈希,該元素被分配到某個桶當中,桶使用鏈表實現,鏈表的節點包含了一個key,一個value,以及一個指向下一個節點的指針。

三、幾個核心問題
1. 找下標:如何高效運算以及減少碰撞
當我們拿到一個hashCode之后,需要將整型的hashCode轉換成鏈表數組中的下標,比如數組大小為n,則下標為:

index = hashCode % n;
1
這里的取模運算效率較低,如果能夠使用位運算(&)來代替取模運算(%),效率將有所提升。位運算直接對內存數據進行操作,不需要轉成十進制,處理速度非常快。

我們可以使用以下方法來實現:

index = hashCode & (n-1);
1
hashCode 與 n-1 進行按位與操作,得到的結果必定是小於n的。

但是,以上按位與的操作跟取模運算並不等價,這可能會帶來index分布不均勻問題。

舉個例子,假設數組大小為15,則hash值在與14(即 1110)進行&運算時,得到的結果最后一位永遠都是0,即 0001、0011、0101、0111、1001、1011、1101、1111位置處是不可能存儲數據的。這樣,空間的減少會導致碰撞幾率的進一步增加,從而就會導致查詢速度慢。

如果能夠保證按位與的操作跟取模運算是等價的,那么不同的hash值發生碰撞的概率比較小,這樣就會使得數據在table數組中分布較均勻,查詢速度也較快。

那么,為什么如何實現位運算(&)跟取模運算(%)的等價呢?我們看以下等式:

X % 2^n = X & (2^n – 1)
1
2^n表示2的n次方,也就是說,一個數對2^n取模 == 一個數和(2^n – 1)做按位與運算 。

假設n為3,則2^3 = 8,表示成二進制就是1000。2^3-1 = 7 ,表示成二進制就是0111。

此時X & (2^3 – 1) 就相當於取X的二進制的最后三位數。

從二進制角度來看,X / 2^n 相當於 X >> n,即把X右移n位,被移掉的部分(后n位),則是X % 2^n,也就是余數。

因此,計算 X % 2^n,實際上就是要獲取 X 的后n位。

我們注意到,2^n 的后 n+1 位都是1,其余為0,於是 2^n-1 的后 n 位都是1,其余為0。

因此,X 跟 2^n-1 做按位與運算,將得到X 的后n位。

所以,只要保證數組的大小是2^n,就可以使用位運算來替代取模運算了。

因此,當拿到一個用戶指定的數組大小時,我們總是會再做一層處理,以保證實際的數組大小為 2^n:

1 size_t getTableSize(size_t capacity) {
2     // 計算超過 capacity 的最小 2^n 
3     size_t ssize = 1;
4     while (ssize < capacity) {
5         ssize <<= 1;
6     }
7     return ssize;
8 }

2. 哈希策略:如何將元素均勻地分配到各個桶內

由於我們將使用key的hashCode來計算該元素在數組中的下標,所以我們希望hashCode是一個size_t類型。所以我們的哈希函數最首要的就是要把各種類型的key轉換成size_t類型,以下是代碼實現:

  1 #ifndef cache_hash_func_H__
  2 #define cache_hash_func_H__
  3 
  4 #include <string>
  5 
  6 namespace HashMap {
  7 
  8 /**
  9  * hash算法仿函數
 10  */
 11 template<class KeyType>
 12 struct cache_hash_func {
 13 };
 14 
 15 inline std::size_t cache_hash_string(const char* __s) {
 16     unsigned long __h = 0;
 17     for (; *__s; ++__s)
 18         __h = 5 * __h + *__s;
 19     return std::size_t(__h);
 20 }
 21 
 22 template<>
 23 struct cache_hash_func<std::string> {
 24     std::size_t operator()(const std::string & __s) const {
 25         return cache_hash_string(__s.c_str());
 26     }
 27 };
 28 
 29 template<>
 30 struct cache_hash_func<char*> {
 31     std::size_t operator()(const char* __s) const {
 32         return cache_hash_string(__s);
 33     }
 34 };
 35 
 36 template<>
 37 struct cache_hash_func<const char*> {
 38     std::size_t operator()(const char* __s) const {
 39         return cache_hash_string(__s);
 40     }
 41 };
 42 
 43 template<>
 44 struct cache_hash_func<char> {
 45     std::size_t operator()(char __x) const {
 46         return __x;
 47     }
 48 };
 49 
 50 template<>
 51 struct cache_hash_func<unsigned char> {
 52     std::size_t operator()(unsigned char __x) const {
 53         return __x;
 54     }
 55 };
 56 
 57 template<>
 58 struct cache_hash_func<signed char> {
 59     std::size_t operator()(unsigned char __x) const {
 60         return __x;
 61     }
 62 };
 63 
 64 template<>
 65 struct cache_hash_func<short> {
 66     std::size_t operator()(short __x) const {
 67         return __x;
 68     }
 69 };
 70 
 71 template<>
 72 struct cache_hash_func<unsigned short> {
 73     std::size_t operator()(unsigned short __x) const {
 74         return __x;
 75     }
 76 };
 77 
 78 template<>
 79 struct cache_hash_func<int> {
 80     std::size_t operator()(int __x) const {
 81         return __x;
 82     }
 83 };
 84 
 85 template<>
 86 struct cache_hash_func<unsigned int> {
 87     std::size_t operator()(unsigned int __x) const {
 88         return __x;
 89     }
 90 };
 91 
 92 template<>
 93 struct cache_hash_func<long> {
 94     std::size_t operator()(long __x) const {
 95         return __x ^ (__x >> 32);
 96     }
 97 };
 98 
 99 template<>
100 struct cache_hash_func<unsigned long> {
101     std::size_t operator()(unsigned long __x) const {
102         return __x ^ (__x >> 32);
103     }
104 };
105 
106 }

可以看到,上面實現的hash函數比較隨意,難以產生較為均勻(即沖突少)的hashCode。

為了防止質量低下的hashCode()函數實現,我們使用getHash()方法對一個對象的hashCode進行重新計算:(下面這個就是hash方法的精髓)

1 size_t getHash(size_t h) const {
2     h ^= (h >>> 20) ^ (h >>> 12);
3     return h ^ (h >>> 7) ^ (h >>> 4);
4 }

這段代碼對key的hashCode進行擾動計算,防止不同hashCode的高位不同但低位相同導致的hash沖突。也就是說,盡量做到任何一位的變化都能對最終得到的結果產生影響。

getHash的更多實現解析可參考:

全網把Map中的hash()分析的最透徹的文章,別無二家。http://www.hollischuang.com/archives/2091

3. 多線程:如何實現無讀鎖,低寫鎖
在數據結構上,我們使用多個桶來存放數據,當哈希足夠均勻時,沖突將比較少。當多線程操作不同的鏈表時,完全不需要加鎖,但是如果操作的是同一個鏈表,則需要加鎖來保證正確性。因此多個桶的設計,從降低鎖的粒度的角度,已經減少了很多不必要的加鎖操作。

同時,單向鏈表的使用,給我們帶來了一個意想不到的好處:多個讀線程和一個寫線程並發操作不會出問題。

假設鏈表中目前包含A和B節點,此時要在它們之間插入C節點,步驟如下:
1. 創建C節點
2. 將C的next指向B
3. 將A的next指向C

在完成1和2兩步之后,讀線程查詢鏈表只能看到A和B,鏈表是完整的。
在第3 步,修改next指針的操作是原子的,因此無論什么時候,讀線程看到的鏈表都是完整的,數據沒有丟失。因此讀操作是不需要加鎖的。

讀操作代碼:

entry_ptr get(const KeyType & key) {
if (m_count != 0) { // read-volatile
for (entry_ptr entry = m_head; entry; entry = entry->getNext()) {
if (entry->equalsKey(key)) {
return entry;
}
}
}
static entry_ptr EMPTY = NULL;
return EMPTY;
}

當多個線程同時執行插入時,由於next的修改可能會被覆蓋,從而造成內存泄漏,因此寫需要加鎖。(當然這里也可以考慮CAS無鎖化,效率方面看應用場景)

寫操作代碼:

//返回值表示key是否已經存在, 已存在返回true
bool set(const KeyType & key, const ValueType & value) {
entry_ptr entry = get(key);
// 如果key已經存在,直接修改
if (entry) {
entry->setValue(value);
return true;
}
LockType lock(m_lock);
// double check,if之后,加鎖之前,entry可能被賦值了
// 因此加完鎖要再檢查一遍
entry = get(key);
if (entry) {
entry->setValue(value);
return true;
}
m_head = new entry_type(key, value, m_head);
++m_count;
return false;
}

由於我們的實現中,不對桶進行擴容,不支持刪除,因此簡化很多。對於鏈表新增的節點,均插入到頭部即可。
第二部分,std::unordered_map實現自定義key

1. unordered_map的定義
下面是unordered_map的官方定義。

template<class Key,
class Ty,
class Hash = std::hash<Key>,
class Pred = std::equal_to<Key>,
class Alloc = std::allocator<std::pair<const Key, Ty> > >
class unordered_map;
> class unordered_map


第1個參數,存儲key值。

第2個參數,存儲mapped value。

第3個參數,為哈希函數的函數對象。它將key作為參數,並利用函數對象中的哈希函數返回類型為size_t的唯一哈希值。默認值為std::hash<key>。

第4個參數,為等比函數的函數對象。它內部通過等比操作符’=='來判斷兩個key是否相等,返回值為bool類型。默認值是std::equal_to<key>。在unordered_map中,任意兩個元素之間始終返回false。

2. 問題分析
對於unordered_map而言,當我們插入<key, value>的時候,需要哈希函數的函數對象對key進行hash,又要利用等比函數的函數對象確保插入的鍵值對沒有重復。然而,當我們自定義類型時,c++標准庫並沒有對應的哈希函數和等比函數的函數對象。因此需要分別對它們進行定義。

因為都是函數對象,它們兩個的實際定義方法並沒有很大差別。不過后者比前者多了一個方法。因為等比函數的函數對象默認值std::equal_to<key>內部是通過調用操作符"=="進行等值判斷,因此我們可以直接在自定義類里面進行operator==()重載(成員和友元都可以)。

因此,如果要將自定義類型作為unordered_map的鍵值,需如下兩個步驟:

定義哈希函數的函數對象;

定義等比函數的函數對象或者在自定義類里重載operator==()。

3. 定義方法
本文所有案例在用g++編譯時,需加上-std=c++11或者-std=c++0x;如果用VS編譯,請選擇2010年及以上版本。

為了避免重復,下文以討論哈希函數的函數對象為主,參數4則是通過直接在自定義類里面對operator==()進行重載。

我們選一種實現

重載operator()的類

#include <iostream>
#include <string>
#include <unordered_map>
#include <functional>
using namespace std;

class Person{
public:
    string name;
    int age;

    Person(string n, int a){
        name = n;
        age = a;
    }

    bool operator==(const Person & p) const 
    {
        return name == p.name && age == p.age;
    }
};

struct hash_name{
    size_t operator()(const Person & p) const{
        return hash<string>()(p.name) ^ hash<int>()(p.age);// hash<string>()(p.name)就是求這個string對應hash值得方法
    }
};

int main(int argc, char* argv[]){
    unordered_map<Person, int, hash_name> ids; //不需要把哈希函數傳入構造器
    ids[Person("Mark", 17)] = 40561;
    ids[Person("Andrew",16)] = 40562;
    for ( auto ii = ids.begin() ; ii != ids.end() ; ii++ )
        cout << ii->first.name 
        << " " << ii->first.age
        << " : " << ii->second
        << endl;
    return 0;
}

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM