哈希表的实现

news2024/11/15 10:18:47

哈希表概念

二叉搜索树具有对数时间的表现,但这样的表现建立在一个假设上:输入的数据有足够的随机性。哈希表又名散列表,在插入、删除、搜索等操作上具有「常数平均时间」的表现,而且这种表现是以统计为基础,不需依赖输入元素的随机性。

听起来似乎不可能,倒也不是,例如:

假设所有元素都是 8-bits 的正整数,范围 0~255,那么简单得使用一个数组就可以满足上述要求。首先配置一个数组 Q,拥有 256 个元素,索引号码 0~255,初始值全部为 0。每一个元素值代表相应的元素的出现次数。如果插入元素 i,就执行 Q[i]++,如果删除元素 i,就执行 Q[i]--,如果查找元素 i,就看 Q[i] 是否为 0。

哈希概念

这个方法有两个很严重的问题。

  1. 如果元素是 32-bits,数组的大小就是 2 32 = 4 G B 2^{32} = 4 GB 232=4GB,这就太大了,更不用说 64-bits 的数了
  2. 如果元素类型是字符串而非整数,就需要某种方法,使其可用作数组的索引

散列函数

如何避免使用一个太大的数组,以及如何将字符串转化为数组的索引呢?一种常见的方法就是使用某种映射函数,将某一元素映射为一个「大小可接受的索引」,这样的函数称为散列函数。

散列函数应有以下特性:

  • 函数的定义域必须包含需要存储的全部关键字,当散列表有 m 个地址时,其值域在 0 到 m - 1 之间
  • 函数计算出来的地址能均匀分布在整个空间

直接定址法

取关键字的某个线性函数为散列地址: H a s h ( K e y ) = A ∗ K e y + B Hash(Key) = A * Key + B Hash(Key)=AKey+B

优点:简单、均匀

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

使用场景:数据范围比较集中的情况

除留余数法

设散列表的索引个数为 m,取一个不大于 m,但最接近 m 的质数 p 最为除数,按照散列函数: H a s h ( K e y ) = k e y Hash(Key) = key % p Hash(Key)=key,将关键字转化为哈希地址

平方取中法

假设关键字为 1230,它的平方是 1512900,取中间的 3 位 129 作为哈希地址;

再比如关键字为 321,它的平方是 103041,取中间的 3 位 304(或 30)作为哈希地址。

哈希冲突

使用散列函数会带来一个问题:可能有不同的元素被映射到相同的位置。这无法避免,因为元素个数大于数组的容量,这便是「哈希冲突」。解决冲突问题的方法有很有,包括线性探测、二次探测、开散列等。

线性探测

当散列函数计算出某个元素的插入位置,而该位置上已有其他元素了。最简单的方法就是向下一一寻找(到达尾端,就从头开始找),直到找到一个可用位置。

进行元素搜索时同理,如果散列函数计算出来的位置上的元素值与目标不符,就向下一一寻找,直到找到目标值或遇到空。

至于元素的删除,必须采用伪删除,即只标记删除记号,实际删除操作在哈希表重新整理时再进行。这是因为哈希表中的每一个元素不仅表示它自己,也影响到其他元素的位置。

线性探测

从上述插入过程我们可以看出,当哈希表中元素变多时,发生冲突的概率也变大了。由此,我们引出哈希表一个重要概念:负载因子。

负载因子定义为:Q = 表中元素个数 / 哈希表的长度

  • 负载因子越大,剩余可用空间越少,发生冲突可能越大
  • 负载因子越小,剩余可用空间越多,发生冲突可能越小,同时空间浪费更多

因此,控制负载因子是个非常重要的事。对于开放定址法(发生了冲突,就找下一个可用位置),负载因子应控制在 0.7~0.8 以下。超过 0.8,查找时的 CPU 缓存不命中按照指数曲线上升。

二次探测

线性探测的缺陷是产生冲突的数据会堆在一起,这与其找下一个空位置的方式有关,它找空位置的方式是挨着往后逐个去找。二次探测主要用来解决数据堆积的问题,其命名由来是因为解决碰撞问题的方程式 F ( i ) = i 2 F(i) = i^2 F(i)=i2 是个二次方程式。

更具体地说,如果散列函数计算出新元素的位置为 H,而该位置实际已被使用,那么将尝试 H + 1 2 , H + 2 2 , H + 3 2 , . . . , H + i 2 H + 1^2, H + 2^2, H + 3^2, ... , H + i^2 H+12,H+22,H+32,...,H+i2,而不是像线性探测那样依次尝试 H + 1 , H + 2 , H + 3 , . . . , H + i H + 1, H + 2, H + 3, ... , H + i H+1,H+2,H+3,...,H+i

二次探测

大量实验表明:当表格大小为质数,而且保持负载因子在 0.5 以下(超过 0.5 就重新配置),那么就可以确定每插入一个新元素所需要的探测次数不超过 2。

链地址法

这种方法是在每一个表格元素中维护一个链表,在呢个链表上执行元素的插入、查询、删除等操作。这时表格内的每个单元不再只有一个节点,而可能有多个节点。

开散列

节点的定义:

template <class Value>
struct __hashtable_node {
	__hashtable_node* next;
    Value val;
};

哈希表的实现

闭散列

接口总览

template <class K, class V>
class HashTable {
	struct Elem {
		pair<K, V> _kv;
		State _state = EMPTY;
	};
public:
	Elem* Find(const K& key);
	bool Insert(const pair<K, V>& kv);
	bool Erase(const K& key);
private:
	vector<Elem> _table;
	size_t _n = 0;
};

节点的结构

因为在闭散列的哈希表中的每一个元素不仅表示它自己,也影响到其他元素的位置。所以要使用伪删除,我们使用一个变量来表示。

/// @brief 标记每个位置状态
enum State {
	EMPTY,	// 空
	EXIST,	// 有数据
	DELETE	// 有数据,但已被删除
};

哈希表的节点结构,不仅存储数据,还存储状态。

/// @brief 哈希表的节点
struct Elem {
    pair<K, V> _kv;	// 存储数据
    State _state;	// 存储状态	
};

查找

查找的思路比较简单:

  1. 利用散列函数获取映射后的索引
  2. 遍历数组看是否存在,直到遇到空表示查找失败
/// @brief 查找指定 key
/// @param key 待查找节点的 key 值
/// @return 找到返回节点的指针,没找到返回空指针
Elem* Find(const K& key) {
    if (_table.empty()) {
        return nullptr;
    }

    // 使用除留余数法的简化版本,并没有寻找质数
    // 同时,该版本只能用于正整数,对于字符串等需使用其他散列函数
    size_t start = key % _table.size();	
    size_t index = start;
    size_t i = 1;

    // 直到找到空位置停止
    while (_table[index]._state != EMPTY) {
        if (_table[index]._state == EXIST && _table[index]._kv.first == key) {
            return &_table[index];
        }

        index = start + i;
        index %= _table.size();
        ++i;
        // 判断是否重复查找
        if (index == start) {
			return nullptr;
        }
    }
    return nullptr;
}

在上面代码的查找过程中,加了句用于判断是否重复查找的代码。理论上上述代码不会出现所有的位置都有数据,查找不存在的数据陷入死循环的情况,因为哈希表会扩容,闭散列下负载因子不会到 1。

但假如,我们插入了 5 个数据,又删除了它们,之后又插入了 5 个数据,将 10 个初始位置都变为非 EMPTY。此时我们查找的值不存在的话,是会陷入死循环的。

插入

插入的过程稍微复杂一些:

  1. 首先检查待插入的 key 值是否存在
  2. 其次需要检查是否需要扩容
  3. 使用线性探测方式将节点插入
/// @brief 插入节点
/// @param kv 待插入的节点
/// @return 插入成功返回 true,失败返回 false
bool Insert(const pair<K, V>& kv) {
    // 检查是否已经存在
    Elem* res = Find(kv.first);
    if (res != nullptr) {
        return false;
    }

    // 看是否需要扩容
    if (_table.empty()) {
        _table.resize(10);
    } else if (_n > 0.7 * _table.size()) {	// 变化一下负载因子计算,可以避免使用除法
        HashTable backUp;
        backUp._table.resize(2 * _table.size());
        for (auto& [k, s] : _table) {
            // C++ 17 的结构化绑定
            // k 绑定 _kv,s 绑定 _state
            if (s == EXIST) {
                backUp.Insert(k);
            }
        }
        // 交换这两个哈希表,现代写法
        _table.swap(backUp._table);
    }

    // 将数据插入
    size_t start = kv.first % _table.size();
    size_t index = start;
    size_t i = 1;

    // 找一个可以插入的位置
    while (_table[index]._state == EXIST) {
        index = start + i;
        index %= _table.size();
        ++i;
    }
    _table[index]._kv = kv;
    _table[index]._state = EXIST;
    ++_n;
    return true;
}

删除

删除的过程非常简单:

  1. 查找指定 key
  2. 找到了就将其状态设为 DELETE,并减少表中元素个数
/// @brief 删除指定 key 值
/// @param key 待删除节点的 key
/// @return 删除成功返回 true,失败返回 false
bool Erase(const K& key) {
    Elem* res = Find(key);
    if (res != nullptr) {
        res->_state = DELETE;
        --_n;
        return true;
    }
    return false;
}

开散列

接口总览

template <class K, class V>
class HashTable {
	struct Elem {
		Elem(const pair<K, V>& kv) 
			: _kv(kv)
			, _next(nullptr)
		{}

		pair<K, V> _kv;
		Elem* _next;
	};
public:
	Elem* Find(const K& key);
	bool Insert(const pair<K, V>& kv);
	bool Erase(const K& key);
private:
	vector<Elem*> _table;
	size_t _n = 0;
};

节点的结构

使用链地址法解决哈希冲突就不再需要伪删除了,但需要一个指针,指向相同索引的下一个节点。

/// @brief 哈希表的节点
struct Elem {
    Elem(const pair<K, V>& kv) 
        : _kv(kv)
            , _next(nullptr)
        {}

    pair<K, V> _kv;	// 存储数据
    Elem* _next;	// 存在下一节点地址
};

查找

查找的实现比较简单:

  1. 利用散列函数获取映射后的索引
  2. 遍历该索引位置的链表
/// @brief 查找指定 key
/// @param key 待查找节点的 key 值
/// @return 找到返回节点的指针,没找到返回空指针
Elem* Find(const K& key) {
    if (_table.empty()) {
        return nullptr;
    }

    size_t index = key % _table.size();
    Elem* cur = _table[index];
    // 遍历该位置链表
    while (cur != nullptr) {
        if (cur->_kv.first == key) {
            return cur;
        }
        cur = cur->_next;
    }
    return nullptr;
}

插入

开散列下的插入比闭散列简单:

  1. 首先检查待插入的 key 值是否存在
  2. 其次需要检查是否需要扩容
  3. 将新节点以头插方式插入
/// @brief 插入节点
/// @param kv 待插入的节点
/// @return 插入成功返回 true,失败返回 false
bool Insert(const pair<K, V>& kv) {
    // 检查是否已经存在
    Elem* res = Find(kv.first);
    if (res != nullptr) {
        return false;
    }

    // 检查是否需要扩容
    if (_table.size() == _n) {
        vector<Elem*> backUp;
        size_t newSize = _table.size() == 0 ? 10 : 2 * _table.size();
        backUp.resize(newSize);

        // 遍历原哈希表,将所有节点插入新表
        for (int i = 0; i < _table.size(); ++i) {
            Elem* cur = _table[i];
            while (cur != nullptr) {
                // 取原哈希表的节点放在新表上,不用重新申请节点
                Elem* tmp = cur->_next;
                size_t index = cur->_kv.first % backUp.size();
                cur->_next = backUp[index];
                backUp[index] = cur;
                cur = tmp;
            }
            _table[i] = nullptr;
        }
        _table.swap(backUp);
    }

    // 将新节点以头插的方式插入
    size_t index = kv.first % _table.size();
    Elem* newElem = new Elem(kv);
    newElem->_next = _table[index];
    _table[index] = newElem;
    ++_n;
    return true;
}

删除

开散列的删除与闭散列有些许不同:

  1. 获取 key 对应的索引
  2. 遍历该位置链表,找到就删除
/// @brief 删除指定 key 值
/// @param key 待删除节点的 key
/// @return 删除成功返回 true,失败返回 false
bool Erase(const K& key) {
    size_t index = key % _table.size();
    Elem* prev = nullptr;
    Elem* cur = _table[index];
    while (cur != nullptr) {
        if (cur->_kv.first == key) {
            if (prev == nullptr) {
                // 是该位置第一个节点
                _table[index] = cur->_next;
            } else {
                prev->_next = cur->_next;
            }
            delete cur;	// 释放该节点
            --_n;
            return true;
        }
        prev = cur;
        cur = cur->_next;
    }
    return false;
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/400950.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

CMU15-445 Project.4总结

在线测试 Project #4 - Concurrency Control 以下是Project #4的网址&#xff0c;2022FALL的Project #4是实现并发控制&#xff0c;可以分为以下三个任务&#xff1a; 我们首先需要实现一个锁管理器&#xff0c;能够支持 READ_UNCOMMITED、READ_COMMITTED、REPEATABLE_READ…

layui表格合并

先看一下最终合并之后的效果&#xff0c;能对单选、复选框进行按照某一列的合并 最开始的解决方案来自于这篇博客介绍的方法&#xff1a;https://blog.csdn.net/guishifoxin/article/details/81480136 但是还是存在没能解决的问题。 在完善之后达到的效果&#xff1a; 一&…

移动硬盘格式化?想要恢复硬盘那就看这里!

案例&#xff1a;移动硬盘无法打开&#xff0c;提示格式化&#xff1f; “怎么办啊&#xff01;&#xff01;&#xff01;今天下午给同学重装系统&#xff0c;插上自己的移动硬盘&#xff0c;却发现读不出来&#xff0c;提示需要格式化&#xff01;里面有很多东西&#xff0c;…

第十四章 opengl之高级OpenGL(深度测试)

OpenGL深度测试深度测试函数深度值精度深度缓冲的可视化深度冲突防止深度冲突深度测试 前面我们渲染一个3D图片中运用了深度缓冲&#xff1a;防止被阻挡的面渲染到其他面的前面。 深度缓冲就像颜色缓冲(Color Buffer)&#xff08;储存所有的片段颜色&#xff1a;视觉输出&…

JAVA开发(JAVA中的异常)

在java开发与代码运行过程中&#xff0c;我们经常会遇到需要处理异常的时候。有时候是在用编辑器写代码&#xff0c;点击保存的时候&#xff0c;编辑器就提示我们某块代码有异常&#xff0c;强制需要处理。有时候是我们启动&#xff0c;运行JAVA代码的时候的&#xff0c;日志里…

案例06-没有复用思想的接口和sql--mybatis,spring

目录一、背景二、思路&方案问题1优化问题2优化三、总结四、升华一、背景 写这篇文章的目的是通过对没有复用思想接口的代码例子优化告诉大家&#xff0c;没有复用思想的代码不要写&#xff0c;用这种思维方式和习惯来指导我们写代码。 项目中有两处没有复用思想代码&#…

R语言基础(三):运算

接前文 R语言基础(一)&#xff1a;注释、变量 R语言基础(二)&#xff1a;常用函数 4.运算 4.1 数学运算 R语言中支持加减乘除四则运算、乘方运算、求余数(取模)运算&#xff1a; 符号含义示例加法11 结果是2-减法2-1 结果是1*乘法4*5 结果是20/除法4/5 结果是0.8%/%整除(只要…

MySQL 事务隔离

MySQL 事务隔离事务隔离实现事务的启动ACID : 原子(Atomicity)、一致(Consistency)、隔离(Isolation)、永久(Durability) 多个事务可能出现问题 : 脏读 (dirty read) , 不可重复读 (non-repeatable read) , 幻读 (phantom read) 事务隔离级别 : 读未提交 (read uncommitted)…

一篇学习ES

文章目录ES简介1.什么是ElasticSearch2.ElasticSearch的使用案例3.ElasticSearch对比SolrElasticSearch环境搭建1. 下载ES压缩包2. 安装ES服务3. 启动ES服务3. 安装ES的图形化界面插件ES术语1.概述2.索引 index3.类型 type4.字段Field5.映射 mapping6.文档 document7. 接近实时…

制造业数字化转型要注重哪些方面?

近年来&#xff0c;制造业企业数字化转型的话题一直处于行业高热位置。中央经济工作会议作出“大力发展数字经济”的部署&#xff0c;工信部提出要深化产业数字化转型&#xff0c;建设一批全球领先的智能工厂、智慧供应链&#xff0c;并向中小企业场景化、标准化复制推广。 随…

监控体系划分

按采集类型划分 1.基于 Metrics 的监控 基于 Metrics 的监控&#xff0c;背后对应的是度量&#xff08;指标监控&#xff09;系统&#xff0c;监控机器在某段时间内的 CPU 使用率、系统负载&#xff1b; HTTP 请求访问量等。 1.Skywalking 开源地址 &#xff08;既能做调用链监…

Spring-AOP简介案例

Spring-AOP简介&案例 1&#xff0c;AOP简介 Spring有两个核心的概念&#xff0c;一个是IOC/DI&#xff0c;一个是AOP。 对于AOP,我们前面提过一句话是:AOP是在不改原有代码的前提下对其进行增强。 1.1 什么是AOP? AOP(Aspect Oriented Programming)面向切面编程&…

java Spring5 xml配置文件方式实现声明式事务

在java Spring5通过声明式事务(注解方式)完成一个简单的事务操作中 我们通过注解方式完成了一个事务操作 那么 下面 我还是讲一下 基于xml实现声明式事务的操作 其实在开发过程中 大家肯定都喜欢用注解 因为他方便 这篇文章中的xml方式 大家做个了解就好 还是 我们的这张表 记…

ECharts数据可视化--常用图表类型

目录 一.柱状图 1.基本柱状图 1.1最简单的柱状图 ​编辑 1.2多系列柱状图 1.3柱状图的样式 &#xff08;1&#xff09;柱条样式 &#xff08;2&#xff09;柱条的宽度和高度 &#xff08;3&#xff09;柱条间距 &#xff08;4&#xff09;为柱条添加背景颜色 ​编辑 2.堆…

SpringBoot创建和使用

目录 什么是SpringBoot SpringBoot的优点 SpringBoot项目的创建 1、使用idea创建 2、项目目录介绍和运行 Spring Boot配置文件 1、配置文件 2、配置文件的格式 3、properties 3.1、properties基本语法 3.2、读取配置文件 3.3、缺点 4、yml 4.1、优点 4.2、yml基本…

虚拟机下Linux系统磁盘扩容

在VM虚拟机中&#xff0c;我们经常会选择默认磁盘大小20G&#xff0c;用着用着才发现20G不够用&#xff0c;服务启动不了&#xff0c;就很尴尬&#xff0c;让我们今天一起来学习下&#xff0c;如何在虚拟机给磁盘扩容。一&#xff1a;关闭虚拟机&#xff0c;添加硬盘背景&#…

mysql Docker容器的安装(centos版)以及修改docker默认端口、解决1251问题

文章目录一、Docker的安装以及在Docker下进行mysql的安装1、安装Docker2、上传安装包并进行安装并启动docker3、 配置Docker的镜像加速器&#xff0c;这里使用阿里云的镜像4、刷新守护进程&#xff0c;并重启docker&#xff0c;检验镜像是否配置成功5、搜索并下载mysql镜像6、导…

超分扩散模型 SR3 可以做图像去雨、去雾等恢复任务吗?

文章目录前言代码及原文链接主要的点如何进行图像恢复前言 关于扩散模型以及条件扩散模型的介绍&#xff0c;大家可以前往我的上一篇博客&#xff1a;扩散模型diffusion model用于图像恢复任务详细原理 (去雨&#xff0c;去雾等皆可)&#xff0c;附实现代码。 SR3是利用扩散模…

优化Facebook广告ROI的数据驱动方法:从投放到运营

“投放广告并不是最终的目的&#xff0c;关键在于如何最大程度地利用数据驱动的方法来提高广告投放的回报率&#xff08;ROI&#xff09;”Facebook广告是现代数字营销中最为常见和重要的广告形式之一。但是&#xff0c;要让Facebook广告真正发挥作用&#xff0c;需要通过数据驱…

Allegro如何自动添加测试点操作指导

Allegro如何自动添加测试点操作指导 在做PCB设计的时候,在一些应用场合下需要给PCB上的网络添加测试点,如下图 测试点除了可以手动逐个添加之外,Allegro还支持自动添加测试点,具体操作如下 点击Manufacture点击Testprep