高阶数据结构之哈希表基础讲解与模拟实现

程序猿的读书历程:x语言入门—>x语言应用实践—>x语言高阶编程—>x语言的科学与艺术—>编程之美—>编程之道—>编程之禅—>颈椎病康复指南。

前言:

哈希表(Hash Table)是一种高效的键值对存储数据结构,广泛应用于各种需要快速查找的场景,如数据库索引、缓存系统、集合等。它的基本思想是通过哈希函数将键映射到哈希表中的一个位置,从而实现快速的数据插入、删除和查找操作。下面我们将详细介绍哈希表的工作原理、实现方式、优缺点以及应用场景。

一、哈希概念

哈希是一种思想,普遍是通过一个哈希数组来存储数据的。学哈希思想,最重要的就是抓住映射两个字,它是一个无序的数据结构,所以想要找到存储的数据,就必须通过相对应的哈希关系来寻找。

对于该数据结构:
插入元素:
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素:
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
取元素比较,若关键码相等,则搜索成功(一般来说的计算方法都是对值取余)
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称
为哈希表。
以最简单的整形数据来举例,哈希结构想要与整形产生映射关系,最简单的就是跟哈希数组的下标产生映射。如果存储数据的哈希数组大小为10,例如:数据集合{1,7,6,4,5,9},哈希函数设置为:hash(key) = key % capacity;(capacity为存储元素底层空间总的大小)
就通过把他们的值取余一个数组大小10,虽好放入相应下标的数组空间中,这就是最简单的映射关系:把1存储到下标为1的地方,4存储到下标为下标为4的地方,6存储到下标为6的地方。就算存储十位数百位数也是如此操作。
高阶数据结构之哈希表基础讲解与模拟实现_第1张图片

这样,我们就能通过这个映射关系,可以不经过任何比较,一次直接从表中得到要搜索的元素。

二、哈希冲突

但是通过上面的介绍,相信不少童鞋已经发现了,一个下标只能存储一个数据,如果我们有两个数,转换后的下标相同呢?

即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞

倘若数据中发生了哈希冲突,我们应该怎么做呢?

引起哈希冲突的一个原因可能是: 哈希函数设计不够合理 。所以我们要先来了解一下哈希函数的涉及原则:
1、哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有 m 个地址时,其值
域必须在 0 m-1 之间
2、哈希函数计算出来的地址能均匀分布在整个空间中
3、哈希函数应该比较简单

常见的哈希函数主要是有两种,一种是直接定址法:

取关键字的某个线性函数为散列地址: Hash Key = A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
另外一种是除留余数法,也是我们一开始用的这种:
设散列表中允许的 地址数为 m ,取一个不大于 m ,但最接近或者等于 m 的质数 p 作为除数,
按照哈希函数: Hash(key) = key% p(p<=m), 将关键码转换成哈希地址
除此之外还有其他许多的方法,一般来说,哈希函数设计的越巧妙,就越能减少哈希冲突。
当然,在精巧的哈希函数,也难免出现哈希冲突,这个时候就需要我们自己去解决了。
解决哈希冲突两种常见的方法是:闭散列和开散列。

1、闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有
空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置
呢?
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。 就相当于上厕所时,在还有空位当代情况下,第一个你选择的坑位已经有人了,那你这个时候肯定不会站在门口等着,而是会选择旁边的位置。
二次探测:从发生冲突的位置开始,以一定变化向后依次探测空位,比如第1处,第2处,随后是第4处,第8处......
在决定好寻找下一个空位置的方法后,我们就设定好比例,当空位已经不足一个比例时(比如已经有十分之七的位置被使用),这样哈希冲突产生的概率就会大大提高,因此就需要我们对其进行扩容操作,随后把原本的值依照新的存储空间大小来进行新的定址操作。
在使用闭散列方法时,我们删除一个元素,不能把他置为空节点之类,因为如果此节点后续仍有同义词,就会影响其的查找。
于是我们需要定义三个状态:空,删除,有值。我们查找一个元素时,碰见删除或者有值的话,就继续查找,直到碰见空就停止了。插入时,碰见有值就继续寻找空位,遇见空和删除就进行插入操作。
通过上面的介绍,我们会发现开散列的方法并不如我们所想的那样解决哈希冲突,所谓堵不如疏,并没有从本质上解决哈希冲突,而是一昧的选择寻找其他空位。导致哈希函数的一一对照关系并不明显表现出来。
那有没有什么方法那个解决这个缺点呢?
答案是开散列。

2、开散列

开散列:开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个同中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
高阶数据结构之哈希表基础讲解与模拟实现_第2张图片
这样有个好处,就是一定会按照哈希函数对应的关系来进行分配,哪怕我此时在插入一个14,24,也只会找到下标位4的链表,随后插入到此链表中,不会跑到下标为3,5的链表里。
为了防止一条链表的数据过多,影响性能,我们一般也会对其进行扩容操作,而开散列方法,只需要将链表转移到新哈希表中就行,不必要在全部拷贝一份数据。

三、其他数据类型的存储问题

哈希函数采用处理余数法,被模的key必须要为整形才可以处理,我们之前的思路只能解决int类型的存储问题,如果那个值是string,是char,我们又应该怎么解决呢?

string与char类型不能被取余,我们想到,那就把它转化为int类型不就可以了吗。

由于字符串长度我们不能确定,但abcd与acbd两个字符串的ASCII码值确实一样,如果光是ASCII码值之和来计算,难免会出现比较离谱的存储结果。据此,通过研究,我们可以通过一些条件来减少ASCII码值的巧合:

class Str_to_Int
{
public:
    size_t operator()(const string& s)
   {
        const char* str = s.c_str();
        unsigned int seed = 131; // 31 131 1313 13131 131313
        unsigned int hash = 0;
        while (*str)
       {
            hash = hash * seed + (*str++);
       }
        
        return (hash & 0x7FFFFFFF);
   }
};

通过这种处理,就能明显减少巧合的发生,将其分配到正确的地址上。


四、哈希表闭散列线性探测实现

我们先写一个简单的哈希表的闭散列实现来理解一下哈希表的底层逻辑。

#pragma once
#include

// 哈希函数采用除留余数法
template
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

// 哈希表中支持字符串的操作
template<>//这是对前面模板HashFunc的string特化类型
struct HashFunc
{
	size_t operator()(const string& key)
	{
		size_t hash = 0;
		for (auto e : key)
		{
			hash *= 31;//防止abcd与dcba的ASCII码值之和相同
			hash += e;
		}

		return hash;
	}
};

// 以下采用开放定址法,即线性探测解决冲突
namespace open_address
{
	enum State
	{
		EXIST,
		EMPTY,
		DELETE

	};

	template
	struct HashData
	{
		pair _kv;
		State _state = EMPTY;
	};

	template>
	class HashTable
	{
	public:
		HashTable()
		{
			_tables.resize(10);
		}

		bool Insert(const pair& kv)
		{
			if (Find(kv.first))//如果以前插入过相同键值
			{
				return false;
			}

			if ((_n * 10) / _tables.size() >= 7)//扩容
			{
				HashTablenewh;
				newh._tables.resize(2 * _tables.size());

				for (int i = 0; i < _tables.size(); ++i)
				{
					if (_tables[i]._state == EXIST)
					{
						newh.Insert(_tables[i]._kv);
					}
				}
				_tables.swap(newh._tables);
			}

			Hash h;

			size_t index = h(kv.first) % _tables.size();//确定插入下标
			while (_tables[index]._state == EXIST)
			{
				++index;
				index = index % _tables.size();
			}
			_tables[index]._state = EXIST;
			_tables[index]._kv = kv;
			++_n;
			return true;
		}

		HashData* Find(const K& key)
		{
			Hash h;
			size_t index = h(key) % _tables.size();//确定查找下标


			while (_tables[index]._state != EMPTY)
			{
				if ( key == _tables[index]._kv.first)
				{
					return &_tables[index];
				}
				++index;
				index %= _tables.size();
			}

			return nullptr;
		}
		bool Erase(const K& key)
		{
			HashData* ret = find(key);
			if (ret)
			{
				ret->_state = DELETE;
				return true;
			}
			else
			{
				return false;
			}
		}

	private:
		vector> _tables;
		size_t _n = 0;  // 表中存储数据个数
	};
}

慢慢看这层代码。

我们用K代表key值,V代表Value值,用Hash来代表一个模板函数,这个函数是为了实现我们的转化key值的作用(就是string类型的key转化为int值)。

我们首先实现了哈希函数的模板,让任意类型的K值得以转化为int类型的参数。注意:对于能够转化为int类型的内置类型,我们直接使用强制转化就行,但是对于经常常用到的string,却又不能直接转换为int,我们就可以写一个特化,要求当K为string时直接调用我们的特化函数就行了。

随后在我们的作用于中定义一个枚举类型,代表上面说的三个状态:存在,空,删除。

寻常的内置类型自然不会包含我们才定义的枚举状态,自然就需要定义一个自定义类型。于是HashData出世了。

随后就是平常的接口的编写:

对于find接口,如果我们找到了对应的值,就需要返回这个值的指针HashData*,如果没找到,就返回空指针。而查找就是先通过Hash,来找到初始的键值处,开始线性查找直到找到或者为空找不到。

对于insert插入接口,我们先判断是否已经插入过相同键值,然后在判断是否达到扩容标准,如果达到了,就进行扩容操作(创建一个新的哈希数组,随后复用insert进行插入,最后交换两个哈希数组就行,新创建的会自动进行销毁)。扩容后,也是先通过Hash,来找到初始的键值,但我们这次应该通过线性探测来查找空位置或者删除的位置。

对于erase接口,我们可以先复用find找到相应的位置,随后把其的_state属性改为delete就行,不必进行数据内容上的修改。我们访问任意一个地址,都是先判断其state属性是否满足条件。

五、哈希表开散列哈希桶的实现

先看代码:

#pragma once
#include


template
struct HashFunc//哈希函数,把K类型转化为int
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
}; 
template<>
struct HashFunc//当K类型时string时的特化函数
{
	size_t operator()(const string& s)
	{
		size_t ret = 0;
		for (auto &it : s)//我们这里对string的每个字母采用乘以31再相加的方法
		{
			ret *= 31;
			ret += it;
		}
		return ret;
	}
};



namespace hash_bucket
{
	template
	struct HashNode//哈希桶存储的单链表的节点结构
	{
		T _data;
		HashNode* next;
		HashNode(const T&data)
			:_data(data)
			,next(nullptr)
		{}
	};

	template>
	class HashTable
	{
		struct keyofT//我这里的实现方法有些特殊,多增加了一个keyofT函数,这个函数时为了后面用哈希桶实现unordered_map与unordered_set
			//而实现的,由于那个时候哈希桶才是底层,所以现在只使用底层代码就会变得奇怪
		{
			const K& operator()(const T&kv)//传递一个string ,int类型的参数就得
				//HashTable>hash;
			{
				return kv.first;
			}
		};
	public:
		typedef HashNode Node;
		HashTable()
			:_n(0)
		{
			_tables.resize(10);
		}

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

		bool insert(const T& data)
		{
			Hash h;
			if (_n >= _tables.size())//扩容
			{
				vector newtables(_tables.size() * 2, nullptr);
				for (int i = 0; i < _tables.size(); ++i)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						Node* next = cur->next;
						size_t newindex = h(keyofT()(cur->_data)) % newtables.size();
						cur->next = newtables[newindex];
						newtables[newindex] = cur;
						cur = next;   
					}
					_tables[i] = nullptr;
				}
				_tables.swap(newtables);
			}
			Node* newnode = new Node(data);
			size_t index = h(keyofT()(data)) % _tables.size();
			newnode->next = _tables[index];
			_tables[index] = newnode;
			++_n;

			return true;
		}

		Node* find(const K& key)
		{
			Hash h;
			size_t index = h(key) % _tables.size();
			Node* cur = _tables[index];
			while (cur)
			{
				if (cur->_data.first == key)
				{
					return cur;
				}
				else
				{
					cur = cur->next;
				}
			}

			return nullptr;
		}


		bool erase(const K& key)
		{
			Hash h;
			size_t index = h(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[index];
			while (cur)
			{
				if (cur->_data.first == key)
				{
					if (prev == nullptr)
					{
						_tables[index] = cur->next;
					}
					else
					{
						prev->next = cur->next;
					}

					delete cur;
					cur = nullptr;
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->next;
				}
			}
			return false;
		}
	private:
		vector_tables;
		size_t _n;
	};
};

相较于闭散列,stl库里实现unordered_map与unordered_set两个容器时底层都用的开散列,所以我这里的开散列实现的有些奇怪,增加的keyofT函数更有利于后续的封装容器。

但是大体结构仍然没有改变,同样用到了Hash来解决不同类型转化为int的问题。唯一值得一提的就是由于我们的节点是指针的链接方式,所以扩容时,我们不需要再赋值节点,只需要把每个节点指针插入到新的哈希table里进行交换就行。

六、哈希表性能分析

哈希表的性能主要取决于哈希函数的设计和哈希冲突的处理方式。哈希表在最理想的情况下,即哈希函数将元素均匀分布到哈希表中时,查找、插入、删除操作的时间复杂度为 O(1)O(1)O(1)。但当发生大量哈希冲突时,时间复杂度可能退化到 O(n)O(n)O(n),这是最坏情况。为了优化性能,我们可以从以下几个方面着手:

  1. 设计良好的哈希函数:哈希函数应尽可能均匀地将元素分布到哈希表中,避免哈希冲突。对数据的特性进行分析,选择合适的哈希函数,如前文提到的直接定址法、除留余数法等。

  2. 扩容:当哈希表中存储的元素个数接近表容量时,哈希冲突的概率会增加,因此需要动态扩容,保持较低的装载因子(如装载因子不超过0.7)。

  3. 合理选择哈希冲突解决策略:开散列(链地址法)通常比闭散列(开放定址法)表现更好,尤其是在高装载因子的情况下,链表法通过链表的结构减少了冲突对性能的影响。

七、哈希表应用场景

哈希表作为一种高效的数据结构,应用非常广泛,特别是在需要快速查找的场景中。例如:

  1. 数据库索引:哈希表在数据库系统中用于索引结构,能够快速查找数据。

  2. 缓存系统:例如Redis等内存缓存系统广泛使用哈希表存储键值对,实现高效的数据存取。

  3. 集合类操作:哈希表在语言标准库中的实现,如C++的unordered_mapunordered_set,用于高效的查找和去重操作。

  4. 字典查找:哈希表是构建字典和符号表的基础,广泛用于自然语言处理、编译器等场景。

八、哈希表的优缺点

优点

  • 查找、插入、删除操作在理想情况下的时间复杂度为 O(1)O(1)O(1),性能非常高效。
  • 实现简单,适合键值对的快速存储和检索。

缺点

  • 在发生大量哈希冲突的情况下,性能可能退化到 O(n)O(n)O(n)。
  • 哈希函数的设计需要谨慎,容易出现偏斜分布,从而影响性能。
  • 哈希表无法保证元素的顺序,适用于无序集合或字典的应用场景。

九、总结

哈希表作为一种重要的数据结构,提供了高效的查找、插入和删除操作。通过设计良好的哈希函数和适当的冲突解决策略,可以最大化哈希表的性能。了解哈希表的工作原理和实现方式,有助于在实际应用中选择合适的解决方案,并有效提升系统的性能。

希望本篇文章对大家有所帮助!

你可能感兴趣的:(c++知识点,数据结构,c++)