哈希结构的代码实现(开散列、闭散列)

哈希结构

unordered系列的关联式容器之所以查找效率比较高,是因为其底层使用了哈希结构。

哈希映射,key值跟存储位置建立映射关系。

哈希表散列表(一种存储结构),通过哈希函数散列函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

常见哈希函数

哈希函数设计原则:哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间;哈希函数计算出来的地址能均匀分布在整个空间中;哈希函数应该比较简单。

  1. 直接定址法–(常用)
    取关键字的某个线性函数为散列地址: H a s h ( K e y ) = A ∗ K e y + B Hash(Key)= A*Key + B HashKey=AKey+B

    优点:简单、均匀、无哈希冲突;
    缺点:需要事先知道关键字的分布情况,一堆整型值哈希映射到表,如果这些值太分散了(如2,3,12,9999),消耗空间太大;
    使用场景:适合查找整型、值小且连续的情况

  2. 除留余数法–(常用)
    设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key % p (p<=m),将关键码转换成哈希地址。有哈希冲突。一般p就取哈希表的size()。

    注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。

哈希冲突

概念

不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突哈希碰撞。简而言之是指不同的值映射到相同位置。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

如下一组数据3、5、7、12、999、13,当采用除留余数法时,取p=10,Hash(key) = Key % p,(p<=m),当遇到3和13时,3%10=3,13%10=3,那这个哈希映射对于的位置到底存3还是存13呢?

解决方法一:闭散列

也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。闭散列是不会让存储空间为满的状态,依靠负载因子来判断是否满了。

哈希表中的存储节点结构定义为

enum State
{
	EMPTY,//该位置为空,可以存放新key
	EXIST,//该位置已经记录了key
	DELETE,//该位置数据被删除,可以存放新key
};

template<class K, class V>
struct HashData
{
	std::pair<K, V> _kv;
	State _state;//要用来标记该位置的状态,用于停止搜索的逻辑判断,当状态为
};

根据key找映射位置

key为整型等

当Key为整型值时,找对应的映射位置方法:Hash(Key)= Key % _table.size()。此处用size不能用capapcity,因为后续用下标来访问对应位置的数据时,operator[]会强制检查访问的位置是否超出有效数据范围size,在实际应用中,一般会让size==capacity,避免空间浪费。

key为字符型–BKDR思路

当Key为其他类型时,需要进行二次映射,此时借助仿函数强制转换来完成。BKDR思路

template<>//特化
struct HashFunc<std::string>
{
	size_t operator()(const std::string& key)
	{
		size_t hash = 0;
		for (auto ch : key)
		{
			hash *= 131;//BKDR思路 每次的结果31、131、13131...
			hash += ch;
		}
		return hash;
	}
};

采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索,因此线性探测采用标记的伪删除法来删除一个元素,即状态置为DELETE。

负载因子–控制扩容

散列表的载荷因子定义为: a = 填入表中的元素个数 / 散列表的长度size。a是散列表装满程度的标志因子。

由于表长是定值,a与“填入表中的元素个数”成正比,所以,a越大,表明填入表中的元素越多,产生冲突的可能性就越大,空间利用率越高;反之,a越小,标明填入表中的元素越少,产生冲突的可能性就越小,空间利用率越低。典型的以空间换时间的做法。

实际上,散列表的平均查找长度是载荷因子a的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cachemissing)按照指数曲线上升。

超过标定的载荷因子,先扩容散列表,再重新建立映射关系

闭散列会占用别的键值对的位置,空间利用率比较低,不太合理。那如何寻找下一个空位置呢?

查找空位置的方法一:线性探测

从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

线性探测优点:实现非常简单;缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。

while (_tables[hashi]._state == EXIST)
{
    //线性探测
    ++hashi;
    hashi %= _tables.size();//避免越界
}

查找空位置的方法二:二次探测

找下一个空位置的方法为: H i H_i Hi = ( H 0 H_0 H0 + i 2 i^2 i2 ) % m, 或者: H i H_i Hi = ( H 0 H_0 H0 - i 2 i^2 i2 ) % m。其中:i =1,2,3…, H 0 H_0 H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小(_tables.size())。

size_t hashi = hf(kv.first) % _tables.size();//Hash()函数

解决方法二:开散列(更优)

又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个无头单链表链接起来,各链表的头结点存储在哈希表中。

当使用无序容器存储键值对时,会先申请一整块连续的存储空间,但此空间并不用来直接存储键值对,而是存储各个链表的头指针,各键值对真正的存储位置是各个链表的节点。STL 标准库通常选用 vector 容器存储各个链表的头指针。

Insert()

当有新键值对存储到无序容器中时,整个存储过程分为

  1. 若负载因子是否超过1.0,要扩容;
  2. 将该键值对中键Key带入设计好的哈希函数,会得到一个哈希值(一个整数,用 H 表示);
  3. 将 H 和无序容器拥有桶的数量 n 做整除运算(即 H % n),该结果即表示应将此键值对存储到的桶的编号;
  4. new一个新节点存储此键值对,同时将该节点头插到相应编号的桶上。
//普通版本的插入
bool Insert(const std::pair<K, V>& kv)
{
    if (Find(kv.first))
        return false;

    if (_tables.size() == _n)//默认情况下,无序容器的最大负载因子为 1.0
    {
        //扩容
        HashTable<K, V, Hash> newHT;
        newHT._tables.resize(_tables.size() * 2);
        for (auto cur : _tables)
        {
            while (cur)
            {
                newHT.Insert(cur->_kv);//每个节点都是重新new再插入的,当数据量大的时候不合适
                cur = cur->_next;
            }
        }
        _tables.swap(newHT._tables);
    }

    Hash hf;
    //根据映射关系找下标
    size_t hashi = hf(kv.first) % _tables.size();

    Node* newnode = new Node(kv);
    newnode->_next = _tables[hashi];
    _tables[hashi] = newnode;
    ++_n;
    return true;
}

普通版本插入的实现,思路是new一个HashTable,让这个表里面的_tables重新扩容,再插入每个节点,能让数据重新排布,减少哈希冲突。不好的地方在于,1、每个节点都是重新new再插入的,当数据量大的时候不合适;2、HashTable有自己的析构函数,当出了函数要调用析构,释放所有节点。==>总的来说,是节点无法重复利用造成的性能消耗。

新的思路是,直接new一个_tables,循环利用旧表节点。对每个旧表节点进行重新映射(这一步不可省略),再头插到新表对应位置,将旧表中存储的指针置空,找不到其他指针,相当于让旧表的析构函数起不了实质性的作用即可。最后交换新旧表即完成扩容。

bool Insert(const std::pair<K, V>& kv)
{
    if (Find(kv.first))
        return false;

    if (_tables.size() == _n)//默认情况下,无序容器的最大负载因子为 1.0
    {
        //扩容
        std::vector<Node*> newtables;
        newtables.resize(_tables.size() * 2, nullptr);
        for (size_t = 0; i < _tables.size(); ++i)
        {
            Node* cur = _tables[i];
            while (cur)
            {
                Node* next = cur->_next;
                size_t hashi = Hash()(cur->_kv->first) % newtables.size();
                //头插到新表						
                cur->_next = newtables[hashi];
                newtables[hashi] = cur;
                //遍历旧表
                cur = next;
            }
            //重复利用旧表节点,让旧表的析构函数析构不了节点
            _tables[i] = nullptr;
        }
        _tables.swap(newtables);//再交换新旧表
    }

    Hash hf;
    //根据映射关系找下标
    size_t hashi = hf(kv.first) % _tables.size();

    Node* newnode = new Node(kv);
    newnode->_next = _tables[hashi];
    _tables[hashi] = newnode;
    ++_n;
    return true;
}

负载因子

哈希表存储结构有一个重要的属性,称为负载因子(load factor)。该属性同样适用于无序容器,用于衡量容器存储键值对的空/满程序,即负载因子越大,意味着容器越满,即各链表中挂载着越多的键值对,这无疑会降低容器查找目标键值对的效率;反之,负载因子越小,容器肯定越空,但并不一定各个链表中挂载的键值对就越少。

负载因子的计算方法为: 负载因子 = 容器存储的总键值对 / 桶数。

if (_tables.size() == _n)//默认情况下,无序容器的最大负载因子为 1.0

**默认情况下,无序容器的最大负载因子为 1.0。**如果操作无序容器过程中,使得最大复杂因子超过了默认值,则容器会自动增加桶数, 并重新进行哈希,以此来减小负载因子的值。需要注意的是,此过程会导致容器迭代器失效,但指向单个键值对的引用或者指针仍然有效。

析构函数

用开散列的哈希结构要自己写析构函数。

~HashTable()
{
    for (size_t i = 0; i < _tables.size(); i++)
    {
        Node* cur = _tables[i];
        while (cur)
        {
            Node* next = cur->_next;
            delete cur;
            cur = next;
        }
        _tables[i] = nullptr;
    }
}

哈希表的size()最好为素数的处理方法

研究人员认为,当模一个素数时,哈希冲突概率更小。

size_t hashi = hf(kv.first) % _tables.size();

// 除留余数法,最好模一个素数,即要求哈希表的size是素数
inline unsigned long __stl_next_prime(unsigned long n)
{
    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
    };

    for (int i = 0; i < __stl_num_primes; ++i)
    {
        if (__stl_prime_list[i] > n)
        {
            return __stl_prime_list[i];
        }
    }
    return __stl_prime_list[__stl_num_primes - 1];
}

在一些场景下,如数据较大、考虑空间和效率等,桶的底层结构可以由单链表改成红黑树。

开散列与闭散列比较

应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。

注意map和set、unordermap和unorderset对key的要求不一样,序列容器的要求key能比较大小;无序容器要求key能转换成整数。

猜你喜欢

转载自blog.csdn.net/m0_61780496/article/details/129713118