《剑指 Offer》专项突破版 - 面试题 30 和 31:详解如何设计哈希表以及利用哈希表设计更加高级、复杂的数据结构

目录

一、哈希表的基础知识

二、哈希表的设计

2.1 - 插入、删除和随机访问都是 O(1) 的容器

2.2 - 最近最少使用缓存



一、哈希表的基础知识

哈希表是一种常见的数据结构,在解决算法面试题的时候经常需要用到哈希表。哈希表最大的优点是高效,在哈希表中插入、删除或查找一个元素都只需要 O(1) 的时间。因此,哈希表经常被用来优化时间效率

在 C++ 中,哈希表有两个对应的类型,即 unordered_set 和 unordered_map。如果每个元素都只有一个值,则用 unordered_set。例如,unordered_set 在图搜索时经常用来存储已经搜索过的节点。如果每个元素都存在一个值到另一个值的映射,那么就用 unordered_map。例如,如果要存储一个文档中的所有单词,同时还关心每个单词在文档中出现的位置,则可以考虑用 unordered_map,单词作为 unordered_map 的键而单词的位置作为值。

尽管哈希表是很高效的数据结构,但这并不意味着哈希表能解决所有的问题。如果用哈希表作为字典存储若干单词,那么只能输入完整的单词在字典中查找。如果对数据集中的元素排序能够有助于解决问题,那么用 set 或 map(第 8 章)可能更合适。如果需要知道一个动态数据集中的最大值或最小值,那么堆(第 9 章)的效率可能更好。如果希望能够根据前缀进行单词查找,如查找字典中所有以 "ex" 开头的单词,那么应该用前缀树(第 10 章)作为实现字典的数据结构。


二、哈希表的设计

与链表、数组等基础数据结构相比,哈希表相对而言要复杂得多。如何设计一个哈希表,是很多面试官非常喜欢的面试题。下面简要地介绍设计一个哈希表的要点。

设计哈希表的前提是待存入的元素需要一个能计算自己哈希值的函数。哈希表根据每个元素的哈希值把它存储到合适的位置

template<class K>
struct DefaultHashFunc
{size_t operator()(const K& key){return (size_t)key;}
};
​
template<>
struct DefaultHashFunc<std::string>
{size_t operator()(const std::string& str){// BKDRHashsize_t hash = 0;size_t seed = 131;for (char ch : str){hash = hash * seed + ch;}return hash;}
};

哈希表最重要的特点就是高效,只需要 O(1) 的时间就可以把一个元素存入或读取。在常用的基础数据结构中,数组满足这个要求。只要知道数组中的下标,就可以用 O(1) 的时间存入或读取一个元素。因此,可以考虑基于数组实现哈希表

把哈希值转换成数组下标可以采用的方法是对数组的长度求余数。如果数组的长度是 4,待存入的元素的哈希值为 5,由于 5 除以 4 的余数是 1,那么它将存入数组下标为 1 的位置。同理,如果待存入的元素的哈希值为 2 和 3,那么它们分别存入下标为 2 和 3 的位置。此时哈希表的状态如下图 (a) 所示。

再在哈希表中存入一个哈希值为 7 的元素。由于 7 除以 4 的余数为 3,因此它存入的位置应该是 3。下标为 3 的位置之前已经被哈希值为 3 的元素占用。如果将两个哈希值不同的元素存入数组中的同一个位置,会引发冲突

为了解决这种冲突,可以把存入数组中同一个位置的多个元素用链表存储。也就是说,数组中每个位置对应的不是一个元素,而是一个链表。哈希值为 3 和 7 的两个元素都存放在数组下标为 3 的位置,因此它们实际上存入了同一个链表中。接下来如果再添加哈希值分别为 4 和 8 的两个元素,那么它们都将被存入数组下标为 0 的位置对应的链表中。此时哈希表的状态如下图 (b) 所示。不难想象,如果在哈希表中添加更多的元素,那么就会有更多的冲突,有更多的元素被存入同一个链表中。

接着考虑如何判断哈希表中是否包含一个元素。例如,为了判断哈希表中是否包含哈希值为 7 的元素,由于 7 除以 4 的余数是 3,因此先找到数组下标为 3 的位置对应的链表,然后从头到尾扫描这个链表查看是否有哈希值为 7 的元素。

在链表中做顺序扫描的时间复杂度为 O(n),链表越长查找需要的时间就越长。这就违背了设计哈希表最重要的一个初衷:存入和读取一个元素的时间复杂度为 O(1)。因此,必须确保链表不能太长

可以计算哈希表中元素的数目与数组长度的比值。显然,在数组长度一定的情况下,存入的元素越多,这个比值就越大;在存入元素的数目一定的情况下,数组的长度越长,这个比值就越小。

当哈希表中元素的数目与数组长度的比值超过某一阈值时,就对数组进行扩容(通常让数组的长度翻倍),然后把哈希表中的每个元素根据新的数组大小求余并存入合适的位置。例如,在下图 (c) 中,扩容之后数组的长度为 8,这个时候原本存入同一链表的 3 和 7 被分别存入下标为 3 和 7 的位置。

通过对数组进行扩容,原本存入同一链表的不同元素就可能会被分散到不同的位置,从而减少链表的长度。当再在哈希表中添加新的元素时,元素的数目与数组长度的比较可能会再次超过阈值,于是需要再次对数组进行扩容。只要阈值设置得合理,哈希表中的链表就不会太长,仍然可以认为存入和读取的时间复杂度都是 O(1)

由此可知,设计哈希表有以下 3 个要点

  1. 为了快速确定一个元素在哈希表中的位置,可以使用一个数组,元素的位置为它的哈希值除以数组长度的余数。

  2. 由于多个哈希值不同的元素可能会被存入同一位置,数组的每个位置都对应一个链表,因此存入同一个位置的多个元素都被添加到同一链表中。

  3. 为了确保链表不会太长,就需要计算哈希表中元素的数目与数组长度的比值。当这个比值超过某个阈值时,就对数组进行扩容并把哈希表中的所有元素重新分配位置。

template<class T>
struct HashNode
{T _data;HashNode<T>* _next;
​HashNode(const T& data): _data(data), _next(nullptr){ }
};
​
template<class K, class T, class KOfT, class HashFunc = DefaultHashFunc<K>>
class HashTable
{typedef HashNode<T> Node;
private:/*---------- 数组的长度最好是素数 ----------*/static size_t GetNextPrime(size_t prime){const int PRIMECOUNT = 28;static const size_t primeList[PRIMECOUNT] ={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};
​size_t i = 0;for (; i < PRIMECOUNT; ++i){if (primeList[i] > prime)return primeList[i];}return primeList[i];}
public:/*---------- 构造函数和析构函数 ----------*/HashTable(): _table(GetNextPrime(0), nullptr), _size(0){ }
​~HashTable(){for (size_t i = 0; i < _table.size(); ++i){Node* cur = _table[i];while (cur){Node* next = cur->_next;delete cur;cur = next;}_table[i] = nullptr;}}
​/*---------- 查找 ----------*/bool find(const K& key){HashFunc hf;size_t hashAddr = hf(key) % _table.size();Node* cur = _table[hashAddr];KOfT kot;while (cur){if (kot(cur->data) == key)return true;cur = cur->_next;}return false;}
​/*---------- 插入 ----------*/bool insert(const T& data){KOfT kot;if (find(kot(data)) == true)return false;HashFunc hf;size_t hashAddr = hf(kot(data)) % _table.size();// 头插Node* newNode = new Node(data);newNode->_next = _table[hashAddr];_table[hashAddr] = newNode;++_size;
​// 将装载因子限制在 1 以下if (_size == _table.size()){size_t newSize = GetNextPrime(_table.size());std::vector<Node*> newTable(newSize, nullptr);for (size_t i = 0; i < _table.size(); ++i){Node* cur = _table[i];while (cur){Node* next = cur->_next;// 将 *cur 头插到新表中size_t hashAddr = hf(kot(cur->data)) % newSize;cur->_next = newTable[hashAddr];newTable[hashAddr] = cur;// 更新 curcur = next;}_table[i] = nullptr;}_table.swap(newTable);}return true;}
​/*---------- 删除 ----------*/bool erase(const K& key){HashFunc hf;size_t hashAddr = hf(key) % _table.size();Node* prev = nullptr;Node* cur = _table[hashAddr];KOfT kot;while (cur){if (kot(cur->_data) == key){if (prev == nullptr)  // 头删_table[hashAddr] = cur->_next;elseprev->_next = cur->_next;
​delete cur;--_size;return true;}
​prev = cur;cur = cur->_next;}return false;}
private:std::vector<Node*> _table;  // 哈希表size_t _size;  // 哈希表中存入的元素个数
};

除了设计哈希表本身,利用哈希表设计更加高级、更加复杂的数据结构也是一种常见的面试题。下面是几道这种类型的经典面试题

2.1 - 插入、删除和随机访问都是 O(1) 的容器

题目

设计一个数据结构,使如下 3 个操作的时间复杂度都是 O(1)。

  • insert(value):如果数据集中不包含一个数值,则把它添加到数据集中。

  • remove(value):如果数据集中包含一个数值,则把它删除。

  • getRandom():随机返回数据集中的一个数值,要求数据集中每个数值被返回的概率都相同

分析

由于题目要求插入和删除(包括数据集中是否包含一个数值)的时间复杂度都是 O(1),能够同时满足这些时间效率要求的只有哈希表,因此这个数据结构要用到哈希表。

但是如果只用哈希表,则不能等概率地返回其中的每个数值。如果数值是保存在数组中的,那么很容易实现等概率返回数组中的每个数值。假设数组的长度是 n,那么等概率随机生成 0 到 n - 1 的一个数字。如果生成的随机数是 i,则返回数组中下标为 i 的数值。因此可以发现,需要结合哈希表和数组的特性来设计这个数据容器

由于数值保存在数组中,因此需要知道每个数值在数组中的位置,否则在删除的时候就必须顺序扫描整个数组才能找到待删除的数值,那么就需要 O(n) 的时间。通常把每个数值在数组中的位置信息保存到一个 unordered_map 中,unordered_map 的键是数值,而对应的值为它在数组中的位置

下面是这种思路的参考代码。在数据容器 RandomizedSet 中,数值保存在用 vector 实现的动态数组 nums 中,而用 unordered_map 实现的哈希表 numToIndex 中存储了每个数值及其在数组 nums 中的下标

代码实现

class RandomizedSet {
public:bool insert(int val) {if (numToIndex.count(val) == 1)return false;nums.push_back(val);numToIndex[val] = nums.size() - 1;return true;}
​bool remove(int val) {if (numToIndex.count(val) == 0)return false;// 避免在数组中删除数组的时候移动数据int removeIndex = numToIndex[val];int lastVal = nums[nums.size() - 1];
​nums[removeIndex] = lastVal;nums.pop_back();
​numToIndex[lastVal] = removeIndex;numToIndex.erase(val);return true;}int getRandom() {int i = rand() % nums.size();return nums[i];}
private:vector<int> nums;unordered_map<int, int> numToIndex;
};

2.2 - 最近最少使用缓存

题目

请设计实现一个最近最少使用(Least Recently Used, LRU)缓存,要求如下两个操作的时间复杂度都是 O(1)。

  • get(key):如果缓存中存在键 key,则返回它对应的值;否则返回 -1。

  • put(key, value):如果缓存中之前包含键 key,则它的值设为 value;否则添加键 key 及对应的值 value。在添加一个键时,如果缓存容量已经满了,则在添加新键之前删除最少使用的键(缓存中最长时间没有被使用过的元素)

分析

使用哈希表可以让 get 操作和 put 操作的时间复杂度都是 O(1),但普通的哈希表无法找出最近最少使用的键,因此,需要在哈希表的基础上进行改进。

由于需要知道缓存中最近最少使用的元素,因此可以把存入的元素按访问的先后顺序存入链表中。每次访问一个元素(无论是通过 get 操作还是通过 put 操作),该元素都被移到链表的尾部。这样,位于链表头部的元素就是最近最少使用的

下面考虑如何实现把一个节点移到链表的尾部。这实际上包含两个步骤,首先要把节点从原来的位置删除,然后把它添加到链表的尾部。需要注意的是,在链表中删除一个节点,实际上是把它的前一个节点的 next 指针指向它的下一个节点。如果这个链表是单向链表,那么找到一个节点的前一个节点需要从链表的头节点开始顺序扫描每个节点,也就需要 O(n) 的时间。

为了快速找到一个节点的前一个节点从而实现用 O(1) 的时间删除一个节点,可以用双向链表来存储缓存中的元素。在双向链表中查找一个节点的前一个节点只需要顺着 prev 指针向前走一步,时间复杂度是 O(1)

因此,设计最近最少使用缓存需要结合哈希表和双向链表的特点。哈希表的键就是缓存的键,哈希表的值为指向双向链表中的节点的指针,每个节点都是一组键与值的数对

例如,在一个容量为 4(即最多只能插入 4 个键)的最近最少使用缓存中依次插入 (1, 1)、(2, 2)、(3, 3) 和 (4, 4) 这 4 个键与值的数对之后,先后在双向链表中插入 4 个节点,如下图 (a) 所示。此时最近最少使用的键是 1,它的节点位于链表的头部。

先执行 get(2)。该操作访问键 2,因此它在双向链表中的节点被移到链表的尾部,如下图 (b) 所示。此时最近最少使用的键仍然是 1。

然后执行 put(1, 8),即更新键 1 对应的值。这个操作访问键 1,因此它在链表中的节点被移到了链表的尾部,如下图 (c) 所示。此时最近最少使用的键是 3。

最后执行 put(5, 5),插入一个新的键 5。由于此时缓存已满,在插入新的键之前要把最近最少使用的键 3 及其对应的值删除。位于链表的最前面的节点的键是 3,将键 3 及其对应的值从哈希表和双向链表中删除之后,再把键 5 添加到哈希表中,同时在链表的尾部插入一个新的节点,如下图 (d) 所示。

代码实现

// 双向链表中的节点的定义
struct Node {int k;int v;Node* prev;Node* next;
​Node(int key = 0, int value = 0) : k(key), v(value), prev(nullptr), next(nullptr){ }
};
​
class LRUCache {
public:LRUCache(int capacity): cap(capacity){head = new Node;tail = new Node;head->next = tail;tail->prev = head;}
​int get(int key) {if (mp.count(key) == 0)return -1;
​Node* node = mp[key];moveToTail(node);return node->v;}
​void put(int key, int value) {if (mp.count(key) == 1){Node* node = mp[key];node->v = value;moveToTail(node);return;}if (mp.size() == cap){Node* node = head->next;removeNode(head->next);mp.erase(node->k);}Node* newNode = new Node(key, value);addToTail(newNode);mp[key] = newNode;}
private:void moveToTail(Node* node) {removeNode(node);addToTail(node);}
​void removeNode(Node* node) {node->prev->next = node->next;node->next->prev = node->prev;}
​void addToTail(Node* node) {tail->prev->next = node;node->prev = tail->prev;tail->prev = node;node->next = tail;}
private:unordered_map<int, Node*> mp;// 为了便于在双向链表中添加和删除节点,增加两个哨兵位节点 head 和 tailNode* head;Node* tail;int cap;
};

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

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

相关文章

1、学习 Eureka 注册中心

学习 Eureka 注册中心 一、创建 Eureka 微服务0、SpringBoot 和 SpringCloud 版本1、引入 Eureka 服务端依赖2、启动类加 EnableEurekaServer 注解3、配置 yaml 文件&#xff0c;把 Eureka 服务注册到 Eureka 注册中心4、访问 Eureka 服务端&#xff0c;查看注册中心的服务列表…

自然语言学习nlp 六

https://www.bilibili.com/video/BV1UG411p7zv?p118 Delta Tuning&#xff0c;尤其是在自然语言处理&#xff08;NLP&#xff09;和机器学习领域中&#xff0c;通常指的是对预训练模型进行微调的一种策略。这种策略不是直接更新整个预训练模型的权重&#xff0c;而是仅针对模型…

Linux 软件管理(YUM RPM)

1 YUM yum&#xff08;全称为 Yellow dog Updater, Modified&#xff09;是一个在Fedora和RedHat以及CentOS中的Shell前端软件包管理器。基于RPM包管理&#xff0c;能够从指定的服务器自动处理依赖性关系&#xff0c;并且一次安装所有依赖的软件包&#xff0c;无须繁琐地一次次…

Aethir和Well-Link Tech携手革新云游戏,释放人工智能(AI)潜力

​Aethir将为Well-Link Tech的2亿用户提供先进的GPU计算能力&#xff0c;大幅提升他们的游戏体验。 新加坡&#xff0c;2024年2月7日 - 先驱性的去中心化GPU网络Aethir与实时云渲染技术领导者Well-Link Tech携手共创云游戏和元宇宙发展的新时代。 借助Well-Link Tech对领先游戏…

[office] Excel2019函数MAXIFS怎么使用?Excel2019函数MAXIFS使用教程 #知识分享#微信#经验分享

Excel2019函数MAXIFS怎么使用&#xff1f;Excel2019函数MAXIFS使用教程 Excel2019函数MAXIFS怎么使用&#xff1f;这篇文章主要介绍了Excel2019函数MAXIFS使用教程,需要的朋友可以参考下 在今年&#xff0c;Excel除了新版本Excel2019&#xff0c;其中有一个新功能MAXIFS函数&am…

初识文件包含漏洞

目录 什么是文件包含漏洞&#xff1f; 文件包含的环境要求 常见的文件包含函数 PHP伪协议 file://协议 php://协议 php://filter php://input zip://、bzip2://、zlib://协议 zip:// bzip2:// zlib:// data://协议 文件包含漏洞演示 案例1&#xff1a;php://inp…

Docker-现代化应用部署的利器

一、容器部署的发展 今天我们来说说容器部署。我们知道容器部署的发展大致分三个阶段&#xff0c;下面来介绍一下不同阶段的部署方式的优缺点 物理机部署 优点是可以提供更高的性能、资源控制&#xff0c;也可以提供更好的数据隔离和安全性&#xff0c;因为不同的应用程序运行在…

从0开始图形学(光栅化)

前言 说起图形学&#xff0c;很多人就会提到OpenGL&#xff0c;但其实两者并不是同一个东西。引入了OpenGL加重了学习的难度和成本&#xff0c;使得一些原理并不直观。可能你知道向量&#xff0c;矩阵&#xff0c;纹理&#xff0c;重心坐标等概念&#xff0c;但就是不知道这些概…

移动端设置position: fixed;固定定位,底部出现一条缝隙,不知原因,欢迎探讨!!!

1、问题 在父盒子中有一个子盒子&#xff0c;父盒子加了固定定位&#xff0c;需要子盒子上下都有要边距&#xff0c;用margin或者padding挤开时&#xff0c;会出现缝隙是子盒子背景颜色的。 测试过了&#xff0c;有些手机型号有&#xff0c;有些没有&#xff0c;微信小程序同移…

vscode +git +gitee 文件管理

文章目录 前言一、gitee是什么&#xff1f;2. Gitee与VScode连接大概步骤 二、在vscode中安装git1.安装git2.安装过程3.安装完后记得重启 三、使用1.新建文件夹first2.vscode 使用 四、连接git1.初始化仓库2.设置git 提交用户和邮箱3.登陆gitee账号新建仓库没有的自己注册一个4…

绕过安全狗

本节我们想要绕过的安全狗版本为v4.023957 &#xff0c;它是网站安全狗的Apache版。 首先搭建环境。渗透环境选用DVWA漏洞集成环境&#xff0c;下载地址 为http://www.dvwa.co.uk/ 。DVWA是一款集成的渗透测试演练环境&#xff0c;当刚刚入门 并且找不到合适的靶机时&#xff…

视觉开发板—K210自学笔记(二)

视觉开发板—K210 一、开发之前的准备 工欲善其事必先利其器。各位同学先下载下面的手册&#xff1a; 1.Sipeed-Maix-Bit 资料下载&#xff1a;https://dl.sipeed.com/shareURL/MAIX/HDK/Sipeed-Maix-Bit/Maix-Bit_V2.0_with_MEMS_microphone 2.Sipeed-Maix-Bit 规格书下载&…

vue3 之 商城项目—登陆

整体认识 登陆页面的主要功能就是表单校验和登陆登出业务 路由配置 模版 <script setup></script><template><div><header class"login-header"><div class"container m-top-20"><h1 class"logo"&g…

在Ubuntu上部署Stable Video Diffusion动画制作

Stable Diffusion团队推出的开源模型Stable Video Diffusion&#xff0c;支持生成约3秒的视频&#xff0c;分辨率为5761024。通过测试视频展示了其令人瞩目的性能&#xff0c;SVD模型是一个生成图像到视频的扩散模型&#xff0c;通过对静止图像的条件化生成短视频。其特点主要包…

Spring基础 - Spring简单例子引入Spring要点

Spring基础 - Spring简单例子引入Spring要点 设计一个Spring的Hello World 设计一个查询用户的案例的两个需求&#xff0c;来看Spring框架帮我们简化了什么开发工作 pom依赖 <?xml version"1.0" encoding"UTF-8"?> <project xmlns"htt…

无人机图像识别技术研究及应用,无人机AI算法技术理论,无人机飞行控制识别算法详解

在现代科技领域中&#xff0c;无人机技术是一个备受瞩目的领域。随着人们对无人机应用的需求在不断增加&#xff0c;无人机技术也在不断发展和改进。在众多的无人机技术中&#xff0c;无人机图像识别技术是其中之一。 无人机图像识别技术是利用计算机视觉技术对无人机拍摄的图像…

springboot169基于vue的工厂车间管理系统的设计

基于VUE的工厂车间管理系统设计与实现 摘 要 社会发展日新月异&#xff0c;用计算机应用实现数据管理功能已经算是很完善的了&#xff0c;但是随着移动互联网的到来&#xff0c;处理信息不再受制于地理位置的限制&#xff0c;处理信息及时高效&#xff0c;备受人们的喜爱。本…

第7章 智能租房——首页

学习目标 掌握房源总数展示功能&#xff0c;能够实现将统计的房源总数在首页中展示 掌握最新房源数据展示功能&#xff0c;能够实现将查询的最新房源数据在首页中展示 掌握热点房源数据展示功能&#xff0c;能够实现将查询的热点房源数据在首页中展示 了解智能提示搜索框的功…

Apache Paimon 文件操作

本文旨在澄清不同文件操作对文件的影响。 本页面提供具体示例和实用技巧&#xff0c;以有效地管理这些操作。此外&#xff0c;通过对提交&#xff08;commit&#xff09;和压实&#xff08;compact&#xff09;等操作的深入探讨&#xff0c;我们旨在提供有关文件创建和更新的见…

STM32 定时器

目录 TIM 定时器定时中断 定时器外部时钟 PWM驱动LED呼吸灯&#xff08;OC&#xff09; PWM控制舵机 PWMA驱动直流电机 输入捕获模式测频率&#xff08;IC&#xff09; 输入捕获模式测占空比 编码器接口测速(编码器接口) TIM 通用定时器 高级定时器 定时器定时中断 Ti…