《剑指 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)
    {
        // BKDRHash
        size_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;
                    // 更新 cur
                    cur = 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;
                else
                    prev->_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 和 tail
    Node* head;
    Node* tail;
    int cap;
};

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【图形图像的C++ 实现 01/20】 2D 和 3D 贝塞尔曲线

目录 一、说明二、贝塞尔曲线特征三、模拟四、全部代码如下 一、说明 以下文章介绍了用 C 计算和绘制的贝塞尔曲线&#xff08;2D 和 3D&#xff09;。    贝塞尔曲线具有出色的数学能力来计算路径&#xff08;从起点到目的地点的曲线&#xff09;。曲线的形状由“控制点”决…

可达鸭二月月赛——入门赛第四场T1题解

姓名 王胤皓 AC 记录 题意 有一个圆桶&#xff0c;底面半径为 r r r &#xff0c;高为 h h h。 问&#xff1a;小可每天都需要喝水 20 20 20 升&#xff0c;请问小可至少需要用这个桶接几杯水呢&#xff1f; 思路 首先求出圆桶能装的水&#xff0c;也就是这个圆桶的体…

上下固定中间自适应布局

实现上下固定中间自适应布局 1.通过position:absolute实现 定义如下结构 <body> <div class="container"> <div class="top"></div> <div class="center"></div> <div class="bottom"&…

Unity BuffSystem buff系统

Unity BuffSystem buff系统 一、介绍二、buff系统架构三、架构讲解四、框架使用buff数据Json数据以及工具ShowTypeBuffTypeMountTypeBuffOverlapBuffShutDownTypeBuffCalculateType时间和层数这里也不过多说明了如何给生物添加buff 五、总结 一、介绍 现在基本做游戏都会需要些…

springboot167基于springboot的医院后台管理系统的设计与实现

简介 【毕设源码推荐 javaweb 项目】基于springbootvue 的 适用于计算机类毕业设计&#xff0c;课程设计参考与学习用途。仅供学习参考&#xff0c; 不得用于商业或者非法用途&#xff0c;否则&#xff0c;一切后果请用户自负。 看运行截图看 第五章 第四章 获取资料方式 **项…

【JS逆向五】逆向模拟某网站的生成的【jsonKey】值 仅供学习

逆向日期&#xff1a;2024.02.07 使用工具&#xff1a;Node.js 加密方法&#xff1a;AES 文章全程已做去敏处理&#xff01;&#xff01;&#xff01; 【需要做的可联系我】 可使用AES进行解密处理&#xff08;直接解密即可&#xff09;&#xff1a;在线AES加解密工具 1、打开…

作业2.8

1、选择题 1.1、以下选项中,不能作为合法常量的是 ____B______ A&#xff09;1.234e04 B&#xff09;1.234e0.4 C&#xff09;1.234e4 D&#xff09;1.234e0 1.2、以下定义变量并初始化错误的是_____D________。 A) char c1 ‘H’ &#xff1b; B) char c…

MPLS VPN功能组件

VPN实例 VPN实例即为VPN路由转发表VRF&#xff0c;不同VPN之间的路由隔离通过VPN实例实现&#xff0c;PE上存在多个路由转发表&#xff0c;包括一个公网路由转发表&#xff0c;以及一个或多个VPN路由转发表。 PE为每个直接相连的Site建立并维护专门的VPN实例&#xff0c;VPN实…

JavaScript表单:事件和方法提交

&#x1f9d1;‍&#x1f393; 个人主页&#xff1a;《爱蹦跶的大A阿》 &#x1f525;当前正在更新专栏&#xff1a;《VUE》 、《JavaScript保姆级教程》、《krpano》、《krpano中文文档》 ​ ​ ✨ 前言 表单 是 web 开发中不可或缺的一部分&#xff0c;用于收集用户输入的…

Bootstrap学习三

Bootstrap学习三 文章目录 前言四、Bootstrap插件4.1. 插件概览4.1.1. data属性4.1.2. 编程方式的API4.1.3. 避免命名空间冲突4.1.4. 事件 4.2. 模态框4.2.1. 引入4.2.2. 基本结构4.2.3. 基本使用4.2.4. 触发模态框的方法 4.3. 下拉菜单和滚动监听4.3.1. 下拉菜单4.3.2. 滚动监…

创建个人的AI智能体agent

我在魔塔空间创建了一个作息时间管理的agent >>>>>点我体验<<<<<< Agent并非ChatGPT升级版&#xff0c;它不仅告诉你“如何做”&#xff0c;更会帮你去做&#xff01; 尽管ChatGPT的知识储备接近“全知全能”&#xff0c;但当你试着以AI助手…

Stable Diffusion 模型下载:ToonYou(平涂卡通)

文章目录 模型介绍生成案例案例一案例二案例三案例四案例五案例六案例七案例八案例九案例十 下载地址 模型介绍 ToonYou 是一个平涂风格的卡通模型&#xff0c;它的画风独特、光感强烈、画面表现力强、场景结构完整&#xff0c;一张张图犹如动画电影截图&#xff0c;非常值得推…

ArcGIS学习(六)地理数据库

ArcGIS学习(六)地理数据库 上个任务我们讲了一个非常重要的知识点一一坐标系。这个任务我们带来另外一个很重要的知识点一一地理数据库。 地理数据库的内容相比于坐标系简单很多! 首先,先让我们来学习下地理数据库的理论。 ArcGIS 中的地理数据库(Geodatabase)是一个用…

一文讲透Python函数中的形式参数和实际参数

函数参数包括形式参数和实际参数&#xff0c;简称形参和实参。其中形式参数即是在定义函数时函数后面括号中的参数列表&#xff08;parameterlist&#xff09;&#xff0c;比如上一个帖子的示例中的width, length&#xff1b;实际参数则是调用函数时函数后面括号中的参数值&…

前端架构: 从vue-cli探究脚手架原理

从使用角度理解什么是脚手架 脚手架本质是一个操作系统的客户端 在终端中去执行一个命令&#xff0c;这个命令本身它就是一个客户端我们其实可以把脚手架理解为操作系统的一个客户端通过命令去执行它的时候&#xff0c;这个命令往往是这样的一个构造&#xff0c;如下 比如&…

Ajax 详解及其使用

Ajax&#xff08;Asynchronous JavaScript and XML&#xff09;是一种在客户端与服务器之间进行异步通信的技术&#xff0c;它允许网页在不重新加载整个页面的情况下&#xff0c;与服务器交换数据并更新部分网页内容。Ajax 的核心是XMLHttpRequest&#xff08;XHR&#xff09;对…

Go 语言中如何大小端字节序?int 转 byte 是如何进行的?

嗨&#xff0c;大家好&#xff01;我是波罗学。 本文是系列文章 Go 技巧第十五篇&#xff0c;系列文章查看&#xff1a;Go 语言技巧。 我们先看这样一个问题&#xff1a;“Go 语言中&#xff0c;将 byte 转换为 int 时是否涉及字节序&#xff08;endianness&#xff09;&#x…

Java SE多态

文章目录 1.多态&#xff1a;1.1.什么是多态&#xff1a;1.2.多态实现条件&#xff1a;1.2.1.重写&#xff1a;1.2.2.向上转型&#xff1a; 1.多态&#xff1a; 1.1.什么是多态&#xff1a; 多态的概念&#xff1a;通俗来说&#xff0c;就是多种形态&#xff0c;具体点就是去…

软件应用实例分享,电玩计时计费怎么算,佳易王PS5游戏计时器系统程序教程

软件应用实例分享&#xff0c;电玩计时计费怎么算&#xff0c;佳易王PS5游戏计时器系统程序教程 一、前言 以下软件教程以 佳易王电玩计时计费管理系统软件V17.9为例说明 软件文件下载可以点击最下方官网卡片——软件下载——试用版软件下载 点击开始计时后&#xff0c;图片…

课时17:本地变量_命令变量

2.2.3 命令变量 学习目标 这一节&#xff0c;我们从 基础知识、简单实践、小结 三个方面来学习。 基础知识 基本格式 定义方式一&#xff1a;变量名命令注意&#xff1a; 是反引号定义方式二&#xff1a;变量名$(命令)执行流程&#xff1a;1、执行 或者 $() 范围内的命令…