数据结构 -hash table

博客主页:【夜泉_ly

本文专栏:【数据结构

欢迎点赞👍收藏⭐关注❤️

文章目录

  • [📚 前言](#📚 前言)
  • [⏫ 直接寻址表](#⏫ 直接寻址表)
    • [📖 简介](#📖 简介)
    • [💻 代码实现](#💻 代码实现)
  • [🗃️ 散列表](#🗃️ 散列表)
    • [📖 简介](#📖 简介)
  • [🔒 闭散列](#🔒 闭散列)
    • [📖 简介](#📖 简介)
    • [💻 代码实现](#💻 代码实现)
  • [🔓 开散列](#🔓 开散列)
    • [📖 简介](#📖 简介)
    • [💻 代码实现](#💻 代码实现)

📚 前言

本文主要内容:

hash的意思是散列,如果音译的话就是哈希。

不过,今天讲的是hash的一部分:hash table,即哈希表。

这两个有什么区别?

  • 哈希 可以认为是一种方法,可以认为是一种思想:将存储的值和存储的位置建立出一种对应的关系。
  • 哈希表是哈希的一种具体应用,是一种支持插入、查找、删除等字典操作的数据结构。

AVL红黑树中,查找的效率被提升至了 O ( l o g 2 N ) O(log_2N) O(log2N),而哈希表更进一步,将平均查找效率提升到 O ( 1 ) O(1) O(1)。虽然在特定的情况下,哈希表的最坏时间复杂度为 O ( N ) O(N) O(N),但在实际中哈希表的查找性能是很好的。

哈希表是普通数组的推广

普通数组一般采用直接寻址,即将key直接作为数组下标。问题是如果key可能的取值范围过大,而存入的元素又过少,就会产生大量的空间浪费。

哈希表则用一个哈希函数,将key和下标建立了对应的关系。但如果不同的key在哈希函数处理后进了同一个下标,这就造成了哈希冲突

哈希冲突 的解决方式有很多,最主要有的两种:开放寻址法、链表法。

⏫ 直接寻址表

📖 简介

直接寻址表DirectAddressTable

直接寻址表就是数组,将key直接作为数组下标。(说实话感觉和计数排序有点像)

适用的情况,数据的全域 U U U不大,且不同数据的key也不同。

简单点就是这么个样子:

💻 代码实现

实现也非常简单:

有时,key就是value;有时,key对应value。我们不管这么多,直接传KV:

cpp 复制代码
template <typename K, typename V>
class DirectAddressTable {

而成员,刚刚说了,是个数组,那我们当然毫不客气的使用vector

cpp 复制代码
private:
    std::vector<std::pair<K, V>*> table; // 用于存储键值对的数组
};

至于这里为什么用pair*,因为算法导论是这么说的,所以我就这么写了。

再来补充一下函数,首先是构造:

cpp 复制代码
public:
    // 构造函数,初始化表大小
    DirectAddressTable(size_t size) 
        : table(size, nullptr) 
    {}

然后是查找、插入、删除,注意不能让key越界:

cpp 复制代码
    std::pair<K, V>* search(const K& key)
    {
        if (key >= table.size() || table[key] == nullptr)
            return nullptr;
        return table[key];
    }

    void insert(const K& key, const V& value)
    {
        assert(key < table.size());
        table[key] = new std::pair<K, V>(key, value);
    }
    
    void erase(const K& key) 
    {
	    assert(key < table.size());
	    delete table[key];
	    table[key] = nullptr;
	}

从此处可以看见,几个字典操作都只需要O(1)的时间。

最后是析构函数,这里需注意的是vector的析构不会释放我们申请的pair,所以需要手动delete一下。而有时我们也需要手动清理一下,因此可以加个clear

cpp 复制代码
	void directAddressClear() 
	{
	    for (auto& e : table) 
	        delete e;
	    table.clear();
	}
	
	~DirectAddressTable()
	{
		clear();
	}

一个简单的直接寻址表就写完了。

当然,还有种更简单的直接寻址表:

cpp 复制代码
	int arr[256] = {0}; // 可以用来统计字符串中每个字符出现的次数

这种情况下,数组的槽(slot)中,直接存的就是key

🗃️ 散列表

📖 简介

散列表即哈希表,用来解决关键字全域U过大,而实际关键字集合K过小的情况。

这时关键字key就不是直接对应下标了,而是利用哈希函数(hash function)hf ,通过关键字计算出下标。

例如有这样一个数组:{1,2,103,104,10005,10006}。

如果是直接寻址,我们需要开一个大小为10007的数组来存储:

如果我们引入一个hfhf(key) = key % 10 计算出相对的位置,那么我们的数组就只需开7个空间:

而不同的key可能会被搞到同一个位置,这就发生了碰撞。

显然,碰撞的次数和我们选的hf有直接关系,但即便hf选的很好,根据抽屉原理,碰撞是不可避免的,因此还需要有方法解决碰撞。在本文,我将介绍两种解决哈希冲突的方法:开放寻址法、链表法。

至于hf,就用个最简单的吧,毕竟其它的我也看不懂:hf(k) = k mod capacity

除了哈希函数以及解决哈希冲突的方法,我们还需要注意一个东西:装载因子(load factor)lf。装载因子就是哈希表的存放元素的个数n除以哈希表的槽位m: l f = n / m lf = n/m lf=n/m。显然装载因子如果过小,那么就代表浪费的空间增多;如果过大,就代表冲突的可能增多。因此装载因子也是一个需要研究的点。(但不是我该研究的点)

🔒 闭散列

📖 简介

闭散列 就是用开放寻址法实现的哈希表,这种方法将所有的元素都存放在散列表里,所以是

其插入的过程,可以简单理解为碰撞了就往后挪一挪。

例如,我们先开个大小为10的数组,然后依次插入8,88,888:

这就是最简单的插入操作。总结一下就是从hf(k)开始,被占了就往后挪挪,找到空了就插入。

因此,这时每个槽有两个状态------空 和 存在

算法导论上说,开放寻址法最好不要删除,但我们还是可以实现一下,虽然会麻烦一点。

还是这个数组:

删除8

由于之前的碰撞,导致一些元素并没有存在对应的hf(k)上,因此在删除后必须标记一个新的状态 删除,不然删了一个元素就可能导致后面的元素找不到了。

删除88,888同理:

这时的插入也需要改一改,改成遇到 删除也需要插入。

至于查找就比较简单了,从hf(k)开始,找到了就返回对象,挪动到状态为就返回空。

💻 代码实现

首先,我们需要定义在槽中存放的数据,与直接寻址法不同,这里还需要加一个状态,而状态分为三种:空 、 存在 以及 删除,根据 effective C++中的条款02,以后我尽量就不用#define了,用enum:

cpp 复制代码
enum STATUS { EMPTY, EXIST, ERASE };

template<class K, class V>
struct Data
{
	std::pair<K, V> _data;
	STATUS _status = EMPTY;
};

存的是一个pair,初始的状态是EMPTY,用的是struct。应该没问题。

然后是哈希表,我比较懒,所以第一步必写typedef

cpp 复制代码
template<class K, class V>
class HashTable
{
	typedef Data<K, V> Data;

成员直接用vector吧,还得再加一个变量_n------用来记录现在存了多少个元素,以便计算负载因子:

cpp 复制代码
private:
	vector<Data> _table;
	size_t _n = 0;
};

_n个初始值,这样就可以直接用编译器提供的默认构造了。吗?

管他的,先把插入写了(暂不考虑扩容):

cpp 复制代码
void insert(const pair<K, V>& kv)
{
	size_t index = kv.first % _table.size(); // hf(k) = k mod capacity
	while (_table[index]._status == EXIST)   // 找到空位
	{
		index = (index + 1) % _table.size();
	}
	_table[index]._data = kv;                // 插入
	_table[index]._status = EXIST;           // 改状态!!!
}

这里一定要注意,插入后要改_status,你也不想辛辛苦苦插入了很多数据最后发现全被覆盖了吧?

写了个测试函数,调用插入时直接报错:

当然,这里我用的hf是:hf(k) = k mod capacity。这个capacity又直接用的vectorsize。因此为0很正常,你或许回想,改成其他的数不就好了,比如:size_t index = kv.first % 10;

很遗憾,还是报错了,因为我们的vector没开空间,所以一插入就会报错。

补个构造:

cpp 复制代码
	HashTable() { _table.resize(10); }

再来看看效果:

	HashTable<int, int> ht;
	ht.insert({8,666});
	ht.insert({88,666});
	ht.insert({888,666});

和预期的一样。那我们继续写删除:

cpp 复制代码
bool erase(const K& k)
{
	size_t index = k % _table.size();
	while (_table[index]._status != EMPTY)
	{
		if (_table[index]._status == EXIST && _table[index]._data.first == k)
		{
			_table[index]._status = ERASE;
			return true;
		}
		index = (index + 1) % _table.size();
	}
	return false;
}

在这里,可以发现查找的代码重复了,于是可以写个private_find,返回的是下标:

cpp 复制代码
size_t _find(const K& k)
{
	size_t index = k % _table.size();
	while (_table[index]._status != EMPTY)
	{
		if (_table[index]._status == EXIST && _table[index]._data.first == k)
		{
			return index;
		}
		index = (index + 1) % _table.size();
	}
	return index;
}

然后就可以改改删除函数:

cpp 复制代码
	bool erase(const K& k)
	{
		size_t index = _find(k);
		if(_table[index]._status == EMPTY)
			return false;
		_table[index]._status = ERASE;
		return true;
	}

再尝试一下:

	HashTable<int, int> ht;
	ht.insert({8,666});
	ht.insert({88,666});
	ht.insert({888,666});
	cout << ht.erase(8) << endl;
	cout << ht.erase(888) << endl;
	cout << ht.erase(88) << endl;
	cout << ht.erase(0) << endl;

输出是:1110。三次成功一次失败,没有问题。

再看监视窗口:

状态都改了,也没问题。

那就来写查找,由于刚刚写了个_find,因此,如果不介意码风的话,甚至可以两行搞定:

cpp 复制代码
	Data* find(const K& k)
	{
		size_t index = _find(k);
		return _table[index]._status == EMPTY ? nullptr : &_table[index];
	}

什么?你说我返回元素的key可以被修改?!

你说得对。但是unordered_map是由STL提供的一款容器,它的find返回的是迭代器,后面忘了。

实在不行,你可以强转一下,我这里就不改了。不然typedef就不能用了

到了这里,这个闭散列就差不多写完了,只需要再处理亿点点小问题。

比如:_n哪儿去了?负载因子呢?Key不是整型怎么办?

没关系,一个一个的解决:

首先是_n,这个好说,插入成功++,删除成功- -。(刚刚忘写了)

然后是负载因子,这个对应的就是扩容问题,我就在负载因子为0.7的时候扩容吧。

如何扩容?首先,直接扩是万万不能的,这会导致关系混乱:

如果我想找 17(标红那个),index = hf(17) = 17 % size = 17,而 index = 17 对应的槽状态为空,因此 17 不存在?

正确的解决方式(之一)是------再建个哈希表插入就行了:

cpp 复制代码
void insert(const pair<K, V>& kv)
{
	if (_n * 10 >= _table.size() * 7)
	{
		size_t newSize = _table.size() * 2;
		HashTable<K,V> newtable;
		newtable._table.resize(newSize);
		for (size_t i = 0; i < _table.size(); i++)
			if (_table[i]._status == EXIST)
				newtable.insert(_table[i]._data);
		_table.swap(newtable._table);
	}

最后一步又捡便宜了,直接用vectorswap,简单省事👍。

现在还剩一个问题:如果key不是整型呢?

这时,仿函数又派上用场了。

普通类型,转成无符号整型就好:

cpp 复制代码
template<class K>
struct defaultHashFunc
{
	size_t operator()(const K& k)
	{
		return (size_t)k;
	}
};

由于stringkey比较比较常见,可以开个后门,来个特化:

cpp 复制代码
template<>
struct defaultHashFunc<string>
{
	size_t operator()(const string& str)
	{
		size_t hash = 0;
		for (auto ch : str)
		{
			hash *= 131;
			hash += ch;
		}
		return hash;
	}
};

这里用的是BKDR的处理方法,具体原理未知,能用就行。

其他的,就让用的人传吧:

cpp 复制代码
template<class K, class V, class HF = defaultHashFunc<K>>
class HashTable
{

由于我把查找放在_find里了,所以只用改这一个地方:

cpp 复制代码
size_t _find(const K& k)
{
	HF hf;
	size_t index = hf(k) % _table.size();

表的完整代码:

cpp 复制代码
template<class K, class V, class HF = defaultHashFunc<K>>
class HashTable
{
	typedef Data<K, V> Data;
public:
	HashTable() { _table.resize(10); }
	void insert(const pair<K, V>& kv)
	{
		if (_n * 10 >= _table.size() * 7)
		{
			size_t newSize = _table.size() * 2;
			HashTable<K,V> newtable;
			newtable._table.resize(newSize);
			for (size_t i = 0; i < _table.size(); i++)
				if (_table[i]._status == EXIST) newtable.insert(_table[i]._data);
			_table.swap(newtable._table);
		}
		size_t index = _find(kv.first);
		_table[index]._data = kv;
		_table[index]._status = EXIST;
		_n++;
	}

	bool erase(const K& k)
	{
		size_t index = _find(k);
		if(_table[index]._status == EMPTY) return false;
		_table[index]._status = ERASE;
		_n--;
		return true;
	}

	Data* find(const K& k)
	{
		size_t index = _find(k);
		return _table[index]._status == EMPTY ? nullptr : &_table[index];
	}

private:
	size_t _find(const K& k)
	{
		HF hf;
		size_t index = hf(k) % _table.size();
		while (_table[index]._status != EMPTY)
		{
			if (_table[index]._status == EXIST && _table[index]._data.first == k) return index;
			index = (index + 1) % _table.size();
		}
		return index;
	}

	vector<Data> _table;
	size_t _n = 0;
};

🔓 开散列

📖 简介

开散列 是用链表法实现的哈希表,这种方法是将元素挂在槽外的,所以是

链表法就是把 hf(k) 相同的 k 全部挂在 index == k 的槽上:

这时,槽里面就不用存状态了,存个指针就行。因此, 代表 nullptr , 代表 挂有数据。

💻 代码实现

首先是挂的节点:

cpp 复制代码
template<class K, class V>
struct Node
{
	pair<K, V> _data;
	Node<K, V>* _pNext;
	Node(const pair<K, V>& kv)
		:_data(kv)
	{}
};

这是我最开始写的,然后一个插入就让我找了十分钟bug😂。

为什么不对,只看这一块代码其实是显而易见的:_pNext没有初始化。

但是在使用时,我会认为Node既然提供了构造函数,那么_pNext应该是初始化了的。

在effective C++的"条款04:确定对象被使用前已被初始化"中,作者指出:规定总是在初始化列表中列出所有成员变量,以免还得记住哪些成员变量可以无需初值。

改正后:

cpp 复制代码
template<class K, class V>
struct Node
{
	pair<K, V> _data;
	Node<K, V>* _pNext;
	Node(const pair<K, V>& kv)
		:_data(kv)
		,_pNext(nullptr)
	{}
};

哈希表的框架,这里的HF就可以用刚刚的了:

cpp 复制代码
template<class K, class V, class HF = defaultHashFunc<K>>
class HashTable
{
	typedef Node<K, V> Node;
public:
	HashTable() { _table.resize(10, nullptr); }
	
private:
	vector<Node*> _table;
	size_t _n = 0;
};

依然是开头就typedef,依然是构造中resize数组,不过这里的vector中存Node*就行了。

然后写插入的时候,遇到了个问题,_find返回什么?

cpp 复制代码
	bool insert(const pair<K, V>& kv)
	{
		? cur = _find(kv.first);

返回size_t的下标吗,那好像也没有缩短多少代码,之后还要在挂着的链表里面继续找;返回Node*吗,感觉没有什么意义。

这时,我突然想到了二叉搜索树的递归写法,那里传的参数是个指针的引用!这样可以直接改变一个节点的_pNext

那这里能不能用引用呢?我想了想,好像可以!

首先是_find

cpp 复制代码
Node*& _find(const K& k)
{
	HF hf;
	size_t index = hf(k) % _table.size();
	return __find(_table[index],k);
}

这里主要是计算出index,然后交给__find去完成真正的查找:

cpp 复制代码
Node*& __find(Node*& cur, const K& k)
{
	return  (!cur || cur->_data.first == k) ? cur : __find(cur->_pNext, k);
}

有点抽象,不过能跑,为了便于讲解,我把__find改改:

cpp 复制代码
Node*& __find(Node*& cur, const K& k)
{
	if(cur == nullptr) return cur;
	if(cur->_data.first == k) return cur;
	return __find(cur->_pNext, k);
}

如果 cur 为空,返回 cur

如果 curk 和 传入的一样,说明重复了,也返回 cur

如果不是上面两种情况,就找cur->_pNext

这里的参数和返回值都是指针的引用,到时候就可以直接改了,非常方便。

然后插入函数就很好写了:

cpp 复制代码
	bool insert(const pair<K, V>& kv)
	{
		Node*& cur = _find(kv.first);
		if (cur) return false;
		cur = new Node(kv);
		_n++;
		return true;
	}

这里相当于还附加了一个去重的效果。(C++是真香,C语言绝对办不到

来分析一下具体的情况吧:

cpp 复制代码
	HashTable<int, int> ht;
	cout << ht.insert({ 1,1 });
	cout << ht.insert({ 1,1 });
	cout << ht.insert({ 11,1 });

第一次插入,插入 1
__find接收_table[index]的引用,发现_table[index]为空,返回_table[index]的引用给_find_find再返回_table[index]的引用给curcur是什么?是指针的引用。哪个指针的引用?_table[index]的引用。改变cur是改变什么?改变_table[index]!!我靠C++是真帅。

第二次插入,插入 1
__find会找到刚刚插入的 1,然后将该节点指针的引用层层返回,cur接收到了这个指针的引用,在判断中,发现 cur 不为空,即重复了,直接返回false

第三次插入,插入 11

同理,最后 cur1 这个节点的 _pNext 的引用,改变cur就是改变 1_pNext!!

去重加插入就这么解决了。

再写写扩容,由于一个槽可以挂多个数据,开散列的装载因子并没有强制要求小于1,这里取1就行。

而实现的思路与闭散列有所不同,这里不能再建一个新哈希表再插入,因为新建和释放节点都要时间,这里可以用一种顺手牵羊的思路,直接把原哈希表上挂的节点挂到新的哈希表上:

cpp 复制代码
	bool insert(const pair<K, V>& kv)
	{
		Node*& cur = _find(kv.first);
		if (cur) return false;
		cur = new Node(kv);
		_n++;

前面操作不变,先插入成功再扩容:

cpp 复制代码
		if (_n >= _table.size())
		{
			HF hf;
			size_t newSize = _table.size() * 2;
			vector<Node*> newTable;
			newTable.resize(newSize, nullptr);
			for (int i = 0; i < _table.size(); i++)
			{
				Node* c = _table[i];
				while (c)
				{
					Node* next = c->_pNext;
					size_t index = hf(c->_data.first) % newSize;
					c->_pNext = newTable[index];
					newTable[index] = c;
					c = next;
				}
				_table[i] = nullptr;
			}
			_table.swap(newTable);
		}
		return true;
	}

删除也很简单:

cpp 复制代码
	bool erase(const K& k)
	{
		Node*& cur = _find(k);
		if (cur == nullptr)return false;
		Node* tmp = cur;
		cur = cur->_pNext;
		delete tmp;
		return true;
	}

如果找到了cur,用一个tmp保存一下,然后让cur = cur->_pNext,最后释放 tmp

查找更简单了:

欸不对,查找写过了,不过写的是private。

那就再套一层吧,前面的懒得改了:

cpp 复制代码
	Node* find(const K& k) { return _find(k); }

表的完整代码:

cpp 复制代码
template<class K, class V, class HF = defaultHashFunc<K>>
class HashTable
{
	typedef Node<K, V> Node;
public:
	HashTable() { _table.resize(10, nullptr); }
	bool insert(const pair<K, V>& kv)
	{
		Node*& cur = _find(kv.first);
		if (cur) return false;
		cur = new Node(kv);
		_n++;

		if (_n >= _table.size())
		{
			HF hf;
			size_t newSize = _table.size() * 2;
			vector<Node*> newTable;
			newTable.resize(newSize, nullptr);
			for (int i = 0; i < _table.size(); i++)
			{
				Node* c = _table[i];
				while (c)
				{
					Node* next = c->_pNext;
					size_t index = hf(c->_data.first) % newSize;
					c->_pNext = newTable[index];
					newTable[index] = c;
					c = next;
				}
				_table[i] = nullptr;
			}
			_table.swap(newTable);
		}
		return true;
	}

	bool erase(const K& k)
	{
		Node*& cur = _find(k);
		if (cur == nullptr)return false;
		Node* tmp = cur;
		cur = cur->_pNext;
		delete tmp;
		return true;
	}

	Node* find(const K& k) { return _find(k); }

private:
	Node*& _find(const K& k)
	{
		HF hf;
		size_t index = hf(k) % _table.size();
		return __find(_table[index],k);
	}

	Node*& __find(Node*& cur, const K& k)
	{
		return  (!cur || cur->_data.first == k) ? cur : __find(cur->_pNext, k);
	}

	vector<Node*> _table;
	size_t _n = 0;
};

希望本篇文章对你有所帮助!并激发你进一步探索编程的兴趣!

本人仅是个C语言初学者,如果你有任何疑问或建议,欢迎随时留言讨论!让我们一起学习,共同进步!

相关推荐
菜鸡中的奋斗鸡→挣扎鸡2 小时前
滑动窗口 + 算法复习
数据结构·算法
axxy20003 小时前
leetcode之hot100---240搜索二维矩阵II(C++)
数据结构·算法
Uu_05kkq4 小时前
【C语言1】C语言常见概念(总结复习篇)——库函数、ASCII码、转义字符
c语言·数据结构·算法
1nullptr6 小时前
三次翻转实现数组元素的旋转
数据结构
TT哇6 小时前
【数据结构练习题】链表与LinkedList
java·数据结构·链表
A懿轩A6 小时前
C/C++ 数据结构与算法【栈和队列】 栈+队列详细解析【日常学习,考研必备】带图+详细代码
c语言·数据结构·c++·学习·考研·算法·栈和队列
1 9 J7 小时前
数据结构 C/C++(实验五:图)
c语言·数据结构·c++·学习·算法
汝即来归8 小时前
选择排序和冒泡排序;MySQL架构
数据结构·算法·排序算法
aaasssdddd9611 小时前
C++的封装(十四):《设计模式》这本书
数据结构·c++·设计模式
芳菲菲其弥章11 小时前
数据结构经典算法总复习(下卷)
数据结构·算法