020-C++之unordered容器

C++之unordered容器

1. unordered系列关联式容器

在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 l o g 2 N log_2 N log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,本文中只对unordered_map和unordered_set进行介绍。

1.1 unordered_map

1.1.1 文档介绍

文档介绍:unordered_map - C++ Reference

1.1.2 常用接口说明
1.1.2.1 构造
cpp 复制代码
// 空构造
unordered_map ( size_type n = /* see below */,
                const hasher& hf = hasher(),
                const key_equal& eql = key_equal(),
                const allocator_type& alloc = allocator_type() );

// 迭代器区间构造
template <class InputIterator>
  unordered_map ( InputIterator first, InputIterator last,
                  size_type n = /* see below */,
                  const hasher& hf = hasher(),
                  const key_equal& eql = key_equal(),
                  const allocator_type& alloc = allocator_type() );

// 拷贝构造
unordered_map ( const unordered_map& ump );

功能:创建一个std::unordered_map

参数:

  1. n (可选,默认值由实现决定)
    • 指定容器的初始桶数量
    • 这是一个提示值,用于预先分配内存。实际桶数量可能由实现根据负载因子等因素调整。
    • 设置一个合适的初始值可以减少后续插入元素时重新哈希的次数,从而提升性能。
    • 如果不指定,库会使用一个默认值。
  2. hf (可选,默认构造的 hasher)
    • 哈希函数对象,用于计算键(key)的哈希值。
    • 默认使用 std::hash<KeyType>
  3. eql (可选,默认构造的 key_equal)
    • 键相等比较器,用于判断两个键是否相等。
    • 默认使用 std::equal_to<KeyType>
  4. alloc (可选,默认构造的 allocator_type)
    • 内存分配器,负责管理容器内部节点的内存分配与释放。
    • 默认使用 std::allocator<std::pair<const Key, T>>
1.1.2.2 容量
cpp 复制代码
// 判空
bool empty() const noexcept;

// 返回有效元素个数
size_type size() const noexcept;
1.1.2.3 迭代器
cpp 复制代码
// 返回指向第一个元素的迭代器
iterator begin() noexcept;

// 返回指向最后一个元素的后一个位置的迭代器
iterator end() noexcept;

// 返回上述迭代器的const版本
const_iterator cbegin() const noexcept;
const_iterator cend() const noexcept;
1.1.2.4 元素访问
cpp 复制代码
// 返回对应Key值的value值引用,如果不存在则创建
mapped_type& operator[] ( const key_type& k );
1.1.2.5 查询
cpp 复制代码
// 查询指定Key,找到返回对应迭代器,没找到返回end迭代器
iterator find ( const key_type& k );

// 统计该容器中拥有指定Key的元素的数量(由于这个容器是不重复的,只会返回0或1)
size_type count ( const key_type& k ) const;
1.1.2.6 修改操作
cpp 复制代码
// 插入元素
// 插入指定元素,返回新的迭代器和插入情况
pair<iterator,bool> insert ( const value_type& val );
// 迭代器区间插入
template <class InputIterator>
    void insert ( InputIterator first, InputIterator last );
    
// 删除元素
// 删除指定迭代器元素,返回被删除元素的后一个迭代器
iterator erase ( const_iterator position );
// 删除指定元素,返回被删除元素的数量
size_type erase ( const key_type& k );
// 删除迭代器区间元素,返回被删除元素的后一个迭代器
iterator erase ( const_iterator first, const_iterator last );

// 清空容器
void clear() noexcept;

// 交换两个容器的内容
void swap ( unordered_map& ump );
1.1.2.7 桶操作
cpp 复制代码
// 定位元素所在的桶,返回桶的编号
size_type bucket ( const key_type& k ) const;

// 返回指定桶中元素的数量
size_type bucket_size ( size_type n ) const;

// 返回同其中桶的数量
size_type bucket_count() const noexcept;

1.2 unordered_set

文档介绍:unordered_set - C++ Reference

unordered_set和unordered_map的接口和使用方法基本一致,这里就不过多赘述。

2. 底层结构

unordered系列容器的效率之所以高,是因为底层使用的哈希结构。

2.1 哈希概念

在顺序结构中,查找一个元素的时间复杂度为O(N),因为需要遍历这个顺序结构。

在平衡树结构中,查找一个元素的时间复杂度为O(logN),即为这颗树的高度次。

而我们理想的方法是不经过任何比较,直接一次从表中获得所需搜索的元素。我们可以构造一种数据结构,通过某种函数是元素的存储位置与它的关键码之间能够建立起一一映射的关系,那么查找时通过该函数,就能很快地找到该元素。

当向结构中:

  • 插入元素:根据待插入元素的关键码,计算出存储位置,并进行存放。
  • 搜索元素:对元素的关键码进行计算,得到存储位置,然后在存储位置找到元素,如果该元素和我们传入的元素的关键码相同,则搜索成功并返回,不相同则意味着该元素不存在。

上述方法即为哈希(散列)方法,哈希方法中,使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(散列表)。

例如我们现在有一个数据集合{1,7,6,4,5,9},哈希函数设置为hash(key) = key % capacity其中capacity为容器底层的空间总大小。

这里的底层为一个数组,我们通过哈希函数将需要查找的元素转化为数组下标,只需要一次就可以找到我们所需要的元素。

2.2 哈希冲突

在上面的例子中,如果再向容器中插入44,那么44通过哈希函数计算出来的值是4,但是这个位置已经被使用了,像这样的不同关键字通过相同和哈希函数得到相同的哈希地址,我们称之为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为"同义词"。

2.3 哈希函数

引起上述原因有可能是因为哈希函数设计的不合理。

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

    取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B;

    优点:简单、均匀;

    缺点:需要事先知道关键字的分布情况;

    使用场景:适合查找范围比较小且连续的情况;

  2. 除留余数法(常用):

    设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址;

  3. 平方取中法(了解):

    假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;

    再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址;

    平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况;

  4. 折叠法(了解):

    折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址;

  5. 随机数法(了解):

    选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数;

  6. 数学分析法(了解):

    设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现;

    可根据哈希表的大小,选择其中各种符号分布均匀的若干位作为散列地址;

    例如:假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。

    数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况。

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

2.4 解决哈希冲突

解决哈希冲突有两种常见方法:闭散列和开散列。

2.4.1 闭散列(开放定址法)

闭散列也叫开放定址法,我们再插入元素时,哈希表中肯定是还有位置的,如果此时发生了哈希冲突,则我们可以将这个元素放在指定位置的下一个空位置。

寻找下一个位置有两种方法:

  1. 线性探测:即从发生冲突的位置开始,依次向后探测,直到找到下一个空位置为止。

    • 插入:这里以上面2.1的情景为例,通过哈希函数找到指定位置,若发生哈希冲突,找到下一个空位,插入新元素。

    • 删除:以上面情景为例,在这里删除不可以直接进行删除,比如这里需要删除4,如果将4的位置直接删除,那么下次需要寻找44的时候,则会因为找不到而直接返回,所以这里应该需要使用伪删除,做一个标记,让下次寻找的这个位置为空时继续往后找,而不是直接返回。

    • 有关扩容:这里引入载荷因子的概念,载荷因子=表中有效元素个数/表的总容量,对应开放定址法,应严格控制在0.7-0.8以下,即一旦超过这个值,则需要进行扩容。

    • 优点:实现非常简单。

    • 缺点:一旦发生冲突,所有的冲突连在一起容易造成数据堆积,这样可能会造成寻找某关键码需要进行多次比较,导致搜索效率下降。

  2. 二次探测:

    线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题。

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

    研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。

因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

2.4.2 开散列(链地址法/开链法)
  1. 开散列概念:

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

​ 从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。

  1. 开散列扩容:

    桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。

  2. 上述方法中只能存储Key为整形的元素,怎么解决?

    哈希函数采用除留余数法,被模的key必须为整形,所以我们必须将key转换为整数,下面提供一种方法:

    cpp 复制代码
    size_t operator()(const string& s)
    {
        const char* str = s.c_str();
        unsigned int seed = 131;
        unsigned int hash = 0;
        while (*str)
        {
        	hash = hash * seed + (*str++);
        }
        return (hash & 0x7FFFFFFF);
    }
2.4.3 开散列与闭散列比较

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

3. 模拟实现哈希表

我们这里采用闭散列的方式进行模拟实现。

为了方便,这里的模拟实现不实现迭代器,这里迭代器的实现也并不难,只需要按顺序遍历每个链表即可。

3.1 哈希表结构

cpp 复制代码
template<typename T>
struct HashTableNode
{
	HashTableNode(const T& data = T()): _prev(nullptr), _next(nullptr), _data(data)
	{}

	HashTableNode<T>* _prev;
	HashTableNode<T>* _next;
	T _data;
};

template<typename K, typename V, typename KOfV, typename HashFunc>
class HashTable
{
private:
	static KOfV _kofv;
	static HashFunc _hf;
    
	using Node = HashTableNode<V>;
public:
	// TODO
private:
	Node* _t;
	size_t _size;
	size_t _capacity;
};

其中:

  • HashTableNode:承载每个元素的链表节点。
  • HashTable:哈希表类
    • typename K:元素中的键值类型
    • typename V:元素类型
    • typename KOfV:从元素中获取键值的仿函数
    • typename HashFunc:哈希函数
    • KOfV _kofv:定义仿函数对象,方便后续使用
    • HashFunc _hf:定义仿函数对象,方便后续使用
    • Node* _t:一个数组,用于存放各链表的头节点
    • size_t _size:哈希表中元素数量
    • size_t _capacity:数组长度(总容量)

3.2 构造/析构

cpp 复制代码
// 取出指定节点
Node* _get(Node* p)
{
	p->_prev->_next = p->_next;
	if (p->_next) p->_next->_prev = p->_prev;
	return p;
}

// 删除所有元素
void _destory()
{
	if (_t == nullptr) return;
	for (int i = 0; i < _capacity; i++)
	{
		while (_t[i]._next)
		{
			Node* del = _get(_t[i]._next);
			delete del;
		}
	}
	_size = 0;
}

HashTable(): _t(nullptr), _size(0), _capacity(0)
{}

HashTable(const HashTable& ht)
{
	for (int i = 0; i < ht._capacity; i++)
	{
		Node* cur = ht._t[i]._next;
		while (cur)
		{
			insert(cur->_data);
			cur = cur->_next;
		}
	}
}

~HashTable()
{
	if (_t)
	{
		_destory();
		delete[] _t;
	}
}

3.3 插入

通过哈希函数计算出对应的哈希值,然后找到该桶,添加到桶中即可。

如果哈希表已满,先扩容。

cpp 复制代码
// 扩容
void _reserve()
{
	int newcapacity = _capacity ? _capacity * 2 : 4;
	Node* t = new Node[newcapacity];
	for (int i = 0; i < _capacity; i++)
	{
		Node* cur = _t[i]._next;
		while (cur)
		{
			Node* next = cur->_next;
			// 将节点从原表中取出
			Node* node = _get(cur);
			// 插入到新表中
			_push(&t[_hf(_kofv(node->_data)) % newcapacity], node);
			cur = next;
		}
	}
	// 释放原表
	delete[] _t;
	_t = t;
	_capacity = newcapacity;
}

std::pair<Node*, bool> insert(const V& value)
{
	// 如果容量已满,扩容
	if (_size == _capacity) _reserve();

	K key = _kofv(value);
	Node* head = &_t[_hf(key) % _capacity];
	Node* cur = head->_next;
	while (cur)
	{
		// 如果该键值已经存在,则直接返回
		if (key == _kofv(cur->_data)) return std::make_pair(cur, false);
		cur = cur->_next;
	}
	// 不存在,添加到该桶中
	Node* n = new Node(value);
	_push(head, n);
	_size++;
}

3.4 查找

先将传入的key转化成哈希值,然后去对应的桶中找,找到返回指针,没找到返回nullptr。

cpp 复制代码
Node* _find(const K& key)
{
	int index = _hf(key) % _capacity;
	Node* cur = _t[index]._next;
	while (cur)
	{
		if (_kofv(cur->_data) == key) return cur;
		cur = cur->_next;
	}
	return nullptr;
}

V* find(const K& key)
{
	Node* ret = _find(key);
	if (ret) return &ret->_data;
	return nullptr;
}

3.5 删除

先根据key找到节点,找到则删除,没找到则什么都不干。

cpp 复制代码
void erase(const K& key)
{
	Node* node = _find(key);
	if (node == nullptr) return;
	delete _get(node);
	_size--;
}

3.6 完整代码

cpp 复制代码
#pragma once

#include <utility>

template<typename T>
struct HashTableNode
{
	HashTableNode(const T& data = T()): _prev(nullptr), _next(nullptr), _data(data)
	{}

	HashTableNode<T>* _prev;
	HashTableNode<T>* _next;
	T _data;
};

template<typename K, typename V, typename KOfV, typename HashFunc>
class HashTable
{
private:
	KOfV _kofv;
	HashFunc _hf;

	using Node = HashTableNode<V>;

	// 取出指定节点
	Node* _get(Node* p)
	{
		p->_prev->_next = p->_next;
		if (p->_next) p->_next->_prev = p->_prev;
		return p;
	}

	// 删除所有元素
	void _destory()
	{
		if (_t == nullptr) return;
		for (int i = 0; i < _capacity; i++)
		{
			while (_t[i]._next)
			{
				Node* del = _get(_t[i]._next);
				delete del;
			}
		}
		_size = 0;
	}
	
	// 在指定节点后插入新的节点
	void _push(Node* p, Node* n)
	{
		n->_next = p->_next;
		n->_prev = p;
		if (p->_next) p->_next->_prev = n;
		p->_next = n;
	}

	// 扩容
	void _reserve()
	{
		int newcapacity = _capacity ? _capacity * 2 : 4;
		Node* t = new Node[newcapacity];
		for (int i = 0; i < _capacity; i++)
		{
			Node* cur = _t[i]._next;
			while (cur)
			{
				Node* next = cur->_next;
				// 将节点从原表中取出
				Node* node = _get(cur);
				// 插入到新表中
				_push(&t[_hf(_kofv(node->_data)) % newcapacity], node);
				cur = next;
			}
		}
		// 释放原表
		delete[] _t;
		_t = t;
		_capacity = newcapacity;
	}

	Node* _find(const K& key)
	{
		int index = _hf(key) % _capacity;
		Node* cur = _t[index]._next;
		while (cur)
		{
			if (_kofv(cur->_data) == key) return cur;
			cur = cur->_next;
		}
		return nullptr;
	}
public:
	HashTable(): _t(nullptr), _size(0), _capacity(0)
	{}

	HashTable(const HashTable& ht)
	{
		for (int i = 0; i < ht._capacity; i++)
		{
			Node* cur = ht._t[i]._next;
			while (cur)
			{
				insert(cur->_data);
				cur = cur->_next;
			}
		}
	}

	~HashTable()
	{
		if (_t)
		{
			_destory();
			delete[] _t;
		}
	}

	std::pair<Node*, bool> insert(const V& value)
	{
		// 如果容量已满,扩容
		if (_size == _capacity) _reserve();

		K key = _kofv(value);
		Node* head = &_t[_hf(key) % _capacity];
		Node* cur = head->_next;
		while (cur)
		{
			// 如果该键值已经存在,则直接返回
			if (key == _kofv(cur->_data)) return std::make_pair(cur, false);
			cur = cur->_next;
		}
		// 不存在,添加到该桶中
		Node* n = new Node(value);
		_push(head, n);
		_size++;
	}

	V* find(const K& key)
	{
		Node* ret = _find(key);
		if (ret) return &ret->_data;
		return nullptr;
	}

	void erase(const K& key)
	{
		Node* node = _find(key);
		if (node == nullptr) return;
		delete _get(node);
		_size--;
	}

	void clear()
	{
		_destory();
	}
private:
	Node* _t;
	size_t _size;
	size_t _capacity;
};

3.4 测试代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>
#include <utility>
#include "HashTable.hpp"

using namespace std;

struct IOfPII
{
	string operator()(const pair<string, int>& p)
	{
		return p.first;
	}
};

struct HashFunc
{
    size_t operator()(const string& s)
    {
        const char* str = s.c_str();
        unsigned int seed = 131;
        unsigned int hash = 0;
        while (*str)
        {
            hash = hash * seed + (*str++);
        }
        return (hash & 0x7FFFFFFF);
    }
};

int main()
{
    HashTable<string, pair<string, int>, IOfPII, HashFunc> ht;
    vector<string> v = { "abc", "aaa", "bbb", "ddd", "yes", "no", "hello" };
    int i = 0;
    for (auto& s : v)
    {
        ht.insert({ s, i });
        i++;
    }

    ht.erase("aaa");
    ht.erase("bbb");
    ht.erase("no");
    
    ht.clear();

    return 0;
}
相关推荐
岛雨QA1 小时前
多路查找树「Java数据结构与算法学习笔记11」
数据结构·算法
AKA__Zas1 小时前
初识基本排序
java·数据结构·学习方法·排序
岛雨QA1 小时前
树结构实际应用「Java数据结构与算法学习笔记10」
数据结构·算法
岛雨QA2 小时前
树结构的基础部分「Java数据结构与算法学习笔记9」
数据结构·算法
会编程的土豆2 小时前
2.25 做题
数据结构·c++·算法
Frostnova丶2 小时前
LeetCode 1356. 根据数字二进制下1的数目排序
数据结构·算法·leetcode
岛雨QA2 小时前
哈希表「Java数据结构与算法学习笔记8」
数据结构·算法
岛雨QA2 小时前
查找算法「Java数据结构与算法学习笔记7」
数据结构·算法
Ljwuhe3 小时前
类与对象(中)——运算符重载
开发语言·c++