• 数据结构与算法(四):哈希表


    参考引用

    1. 哈希表

    1.1 哈希表概述

    • 哈希表(hash table),又称散列表,其通过建立键 key 与值 value 之间的映射,实现高效的元素查询

      • 具体而言,向哈希表输入一个键 key ,则可以在 O(1) 时间内获取对应的值 value
    • 如下图所示,给定 n 个学生,每个学生都有 “姓名” 和 “学号” 两项数据。假如希望实现 “输入一个学号,返回对应的姓名” 的查询功能,则可以采用下图所示的哈希表来实现

    在这里插入图片描述

    • 除哈希表外,数组和链表也可以实现查询功能,它们的效率对比如下图所示
      • 在哈希表中进行增删查改的时间复杂度都是 O(1),非常高效

    在这里插入图片描述

    1.2 哈希表常用操作

    • 哈希表的常见操作包括:初始化、查询操作、添加键值对和删除键值对等
    /* 1、初始化哈希表 */
    unordered_map<int, string> map;
    
    /* 2、添加操作 */
    // 在哈希表中添加键值对 (key, value)
    map[12836] = "小哈";
    map[15937] = "小啰";
    map[16750] = "小算";
    map[13276] = "小法";
    map[10583] = "小鸭";
    
    /* 3、查询操作 */
    // 向哈希表输入键 key ,得到值 value
    string name = map[15937];
    
    /* 4、删除操作 */
    // 在哈希表中删除键值对 (key, value)
    map.erase(10583);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 哈希表有三种常用遍历方式:遍历键值对、遍历键和遍历值
      /* 5、遍历哈希表 */
      // 遍历键值对 key->value
      for (auto kv: map) {
          cout << kv.first << " -> " << kv.second << endl;
      }
      // 单独遍历键 key
      for (auto kv: map) {
          cout << kv.first << endl;
      }
      // 单独遍历值 value
      for (auto kv: map) {
          cout << kv.second << endl;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13

    1.3 哈希表简单实现

    基于数组实现哈希表
    • 在哈希表中,将数组中的每个空位称为桶(bucket),每个桶可存储一个键值对。因此,查询操作就是找到 key 对应的桶,并在桶中获取 value。如何基于 key 来定位对应的桶呢?通过哈希函数(hash function)实现
      • 哈希函数的作用:将一个较大的输入空间映射到一个较小的输出空间
      • 在哈希表中,输入空间是所有 key,输出空间是所有桶(数组索引)。换句话说,输入一个 key,可以通过哈希函数得到该 key 对应的键值对在数组中的存储位置
    • 输入一个 key,哈希函数的计算过程分为以下两步
      • 通过某种哈希算法 hash() 计算得到哈希值
      • 将哈希值对桶数量(数组长度,capacity)取模,从而获取该 key 对应的数组索引 index
      index = hash(key) % capacity
      
      • 1
    • 随后,就可以利用 index 在哈希表中访问对应的桶,从而获取 value。设数组长度 capacity = 100、哈希算法 hash(key) = key,易得哈希函数为 key % 100。下图以 key 学号和 value 姓名为例,展示了哈希函数的工作原理

    在这里插入图片描述

    /* 键值对 */
    struct Pair {
    public:
        int key;
        string val;
        Pair(int key, string val) {
            // 使用 this 指针来引用当前对象的成员变量,将传入的参数赋值给成员变量 key 和 val
            this->key = key;
            this->val = val;
        }
    };
    
    /* 基于数组简易实现的哈希表 */
    class ArrayHashMap {
    private:
        vector<Pair *> buckets;
    
    public:
        ArrayHashMap() {
            // 初始化数组,包含 100 个桶
            buckets = vector<Pair *>(100);
        }
    
        ~ArrayHashMap() {
            // 释放内存
            for (const auto &bucket : buckets) {
                delete bucket;
            }
            buckets.clear();
        }
    
        /* 哈希函数 */
        int hashFunc(int key) {
            int index = key % 100;
            return index;
        }
    
        /* 查询操作 */
        string get(int key) {
            int index = hashFunc(key);
            Pair *pair = buckets[index];
            if (pair == nullptr)
                return "";
            return pair->val;
        }
    
        /* 添加操作 */
        void put(int key, string val) {
            Pair *pair = new Pair(key, val);
            int index = hashFunc(key);
            buckets[index] = pair;
        }
    
        /* 删除操作 */
        void remove(int key) {
            int index = hashFunc(key);
            // 释放内存并置为 nullptr
            delete buckets[index];
            buckets[index] = nullptr;
        }
    
        /* 获取所有键值对 */
        vector<Pair *> pairSet() {
            vector<Pair *> pairSet;
            for (Pair *pair : buckets) {
                if (pair != nullptr) {
                    pairSet.push_back(pair);
                }
            }
            return pairSet;
        }
    
        /* 获取所有键 */
        vector<int> keySet() {
            vector<int> keySet;
            for (Pair *pair : buckets) {
                if (pair != nullptr) {
                    keySet.push_back(pair->key);
                }
            }
            return keySet;
        }
    
        /* 获取所有值 */
        vector<string> valueSet() {
            vector<string> valueSet;
            for (Pair *pair : buckets) {
                if (pair != nullptr) {
                    valueSet.push_back(pair->val);
                }
            }
            return valueSet;
        }
    
        /* 打印哈希表 */
        void print() {
            for (Pair *kv : pairSet()) {
                cout << kv->key << " -> " << kv->val << endl;
            }
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101

    1.4 哈希冲突与扩容

    • 本质上看,哈希函数的作用是将所有 key 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,理论上一定存在 “多个输入对应相同输出” 的情况
    • 对于上述示例中的哈希函数,当输入的 key 后两位相同时,哈希函数的输出结果也相同。例如,查询学号为 12836 和 20336 的两个学生时,得到
      12836 % 100 = 36
      20336 % 100 = 36
      
      • 1
      • 2
    • 如下图所示,两个学号指向了同一个姓名,这显然是不对的。将这种多个输入对应同一输出的情况称为哈希冲突(hash collision)

    在这里插入图片描述

    • 容易想到,哈希表容量越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,可以通过扩容哈希表来减少哈希冲突。如下图所示,扩容前键值对 (136, A) 和 (236, D) 发生冲突,扩容后冲突消失

    在这里插入图片描述

    类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 capacity 改变,需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,通常会预留足够大的哈希表容量,防止频繁扩容

    • 负载因子是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,也常被作为哈希表扩容的触发条件

    2. 哈希冲突

    • 通常情况下哈希函数的输入空间远大于输出空间,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引

    • 哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,可以每当遇到哈希冲突时就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,可以采用以下策略

      • 改良哈希表数据结构,使得哈希表可以在存在哈希冲突时正常工作
      • 仅在哈希冲突比较严重时,才执行扩容操作

    哈希表的结构改良方法主要包括 “链式地址” 和 “开放寻址”

    2.1 链式地址

    • 在原始哈希表中,每个桶仅能存储一个键值对。链式地址(separate chaining)将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。下图展示了一个链式地址哈希表的例子

    在这里插入图片描述

    • 基于链式地址实现的哈希表的操作方法发生了以下变化

      • 查询元素
        • 输入 key,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 key 以查找目标键值对
      • 添加元素
        • 先通过哈希函数访问链表头节点,然后将节点(即键值对)添加到链表中
      • 删除元素
        • 根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点,并将其删除
    • 链式地址存在以下局限性

      • 占用空间增大,链表包含节点指针,它相比数组更加耗费内存空间
      • 查询效率降低,因为需要线性遍历链表来查找对应元素
    /* 链式地址哈希表 */
    class HashMapChaining {
      private:
        int size;                       // 键值对数量
        int capacity;                   // 哈希表容量
        double loadThres;               // 触发扩容的负载因子阈值
        int extendRatio;                // 扩容倍数
        vector<vector<Pair *>> buckets; // 桶数组
    
      public:
        /* 构造方法 */
        HashMapChaining() : size(0), capacity(4), loadThres(2.0 / 3.0), extendRatio(2) {
            buckets.resize(capacity);
        }
    
        /* 析构方法 */
        ~HashMapChaining() {
            for (auto &bucket : buckets) {
                for (Pair *pair : bucket) {
                    // 释放内存
                    delete pair;
                }
            }
        }
    
        /* 哈希函数 */
        int hashFunc(int key) {
            return key % capacity;
        }
    
        /* 负载因子 */
        double loadFactor() {
            return (double)size / (double)capacity;
        }
    
        /* 查询操作 */
        string get(int key) {
            int index = hashFunc(key);
            // 遍历桶,若找到 key 则返回对应 val
            for (Pair *pair : buckets[index]) {
                if (pair->key == key) {
                    return pair->val;
                }
            }
            // 若未找到 key 则返回空字符串
            return "";
        }
    
        /* 添加操作 */
        void put(int key, string val) {
            // 当负载因子超过阈值时,执行扩容
            if (loadFactor() > loadThres) {
                extend();
            }
            int index = hashFunc(key);
            // 遍历桶,若遇到指定 key ,则更新对应 val 并返回
            for (Pair *pair : buckets[index]) {
                if (pair->key == key) {
                    pair->val = val;
                    return;
                }
            }
            // 若无该 key ,则将键值对添加至尾部
            buckets[index].push_back(new Pair(key, val));
            size++;
        }
    
        /* 删除操作 */
        void remove(int key) {
            int index = hashFunc(key);
            auto &bucket = buckets[index];
            // 遍历桶,从中删除键值对
            for (int i = 0; i < bucket.size(); i++) {
                if (bucket[i]->key == key) {
                    Pair *tmp = bucket[i];
                    bucket.erase(bucket.begin() + i); // 从中删除键值对
                    delete tmp;                       // 释放内存
                    size--;
                    return;
                }
            }
        }
    
        /* 扩容哈希表 */
        void extend() {
            // 暂存原哈希表
            vector<vector<Pair *>> bucketsTmp = buckets;
            // 初始化扩容后的新哈希表
            capacity *= extendRatio;
            buckets.clear();
            buckets.resize(capacity);
            size = 0;
            // 将键值对从原哈希表搬运至新哈希表
            for (auto &bucket : bucketsTmp) {
                for (Pair *pair : bucket) {
                    put(pair->key, pair->val);
                    // 释放内存
                    delete pair;
                }
            }
        }
    
        /* 打印哈希表 */
        void print() {
            for (auto &bucket : buckets) {
                cout << "[";
                for (Pair *pair : bucket) {
                    cout << pair->key << " -> " << pair->val << ", ";
                }
                cout << "]\n";
            }
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113

    当链表很长时,查询效率 O(n) 很差。此时可以将链表转换为 “AVL 树” 或 “红黑树”,从而将查询操作的时间复杂度优化至 O(log n)

    2.2 开放寻址

    • 开放寻址(open addressing)不引入额外的数据结构,而是通过 “多次探测” 来处理哈希冲突,探测方式主要包括线性探测、平方探测、多次哈希等
    2.2.1 线性探测
    • 线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同

      • 插入元素
        • 通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 1),直至找到空桶,将元素插入其中
      • 查找元素
        • 若发现哈希冲突,则使用相同步长向后线性遍历,直到找到对应元素,返回 value 即可
        • 如果遇到空桶,说明目标元素不在哈希表中,返回 None
    • 下图展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的 key 都会被映射到相同的桶。而通过线性探测,它们被依次存储在该桶以及之下的桶中

    在这里插入图片描述

    • 然而,线性探测容易产生 “聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化
    • 不能在开放寻址哈希表中直接删除元素。这是因为删除元素会在数组内产生一个空桶 None,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在

    在这里插入图片描述

    • 为了解决该问题,可以采用懒删除(lazy deletion)机制

      • 它不直接从哈希表中移除元素,而是利用一个常量 TOMBSTONE 来标记这个桶
      • 在该机制下,None 和 TOMBSTONE 都代表空桶,都可以放置键值对
      • 但不同的是,线性探测到 TOMBSTONE 时应该继续遍历,因为其之下可能还存在键值对
    • 然而,懒删除可能会加速哈希表的性能退化。这是因为每次删除操作都会产生一个删除标记,随着 TOMBSTONE 的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 TOMBSTONE 才能找到目标元素

      • 为此,考虑在线性探测中记录遇到的首个 TOMBSTONE 的索引,并将搜索到的目标元素与该 TOMBSTONE 交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率
    • 以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表的空间,将哈希表看作是一个 “环形数组”,当越过数组尾部时,回到头部继续遍历

      /* 开放寻址哈希表 */
      class HashMapOpenAddressing {
        private:
          int size;                             // 键值对数量
          int capacity = 4;                     // 哈希表容量
          const double loadThres = 2.0 / 3.0;     // 触发扩容的负载因子阈值
          const int extendRatio = 2;            // 扩容倍数
          vector<Pair *> buckets;               // 桶数组
          Pair *TOMBSTONE = new Pair(-1, "-1"); // 删除标记
      
        public:
          /* 构造方法 */
          HashMapOpenAddressing() : size(0), buckets(capacity, nullptr) {
          }
      
          /* 析构方法 */
          ~HashMapOpenAddressing() {
              for (Pair *pair : buckets) {
                  if (pair != nullptr && pair != TOMBSTONE) {
                      delete pair;
                  }
              }
              delete TOMBSTONE;
          }
      
          /* 哈希函数 */
          int hashFunc(int key) {
              return key % capacity;
          }
      
          /* 负载因子 */
          double loadFactor() {
              return (double)size / capacity;
          }
      
          /* 搜索 key 对应的桶索引 */
          int findBucket(int key) {
              int index = hashFunc(key);
              int firstTombstone = -1;
              // 线性探测,当遇到空桶时跳出
              while (buckets[index] != nullptr) {
                  // 若遇到 key ,返回对应桶索引
                  if (buckets[index]->key == key) {
                      // 若之前遇到了删除标记,则将键值对移动至该索引
                      if (firstTombstone != -1) {
                          buckets[firstTombstone] = buckets[index];
                          buckets[index] = TOMBSTONE;
                          return firstTombstone; // 返回移动后的桶索引
                      }
                      return index; // 返回桶索引
                  }
                  // 记录遇到的首个删除标记
                  if (firstTombstone == -1 && buckets[index] == TOMBSTONE) {
                      firstTombstone = index;
                  }
                  // 计算桶索引,越过尾部返回头部
                  index = (index + 1) % capacity;
              }
              // 若 key 不存在,则返回添加点的索引
              return firstTombstone == -1 ? index : firstTombstone;
          }
      
          /* 查询操作 */
          string get(int key) {
              // 搜索 key 对应的桶索引
              int index = findBucket(key);
              // 若找到键值对,则返回对应 val
              if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {
                  return buckets[index]->val;
              }
              // 若键值对不存在,则返回空字符串
              return "";
          }
      
          /* 添加操作 */
          void put(int key, string val) {
              // 当负载因子超过阈值时,执行扩容
              if (loadFactor() > loadThres) {
                  extend();
              }
              // 搜索 key 对应的桶索引
              int index = findBucket(key);
              // 若找到键值对,则覆盖 val 并返回
              if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {
                  buckets[index]->val = val;
                  return;
              }
              // 若键值对不存在,则添加该键值对
              buckets[index] = new Pair(key, val);
              size++;
          }
      
          /* 删除操作 */
          void remove(int key) {
              // 搜索 key 对应的桶索引
              int index = findBucket(key);
              // 若找到键值对,则用删除标记覆盖它
              if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {
                  delete buckets[index];
                  buckets[index] = TOMBSTONE;
                  size--;
              }
          }
      
          /* 扩容哈希表 */
          void extend() {
              // 暂存原哈希表
              vector<Pair *> bucketsTmp = buckets;
              // 初始化扩容后的新哈希表
              capacity *= extendRatio;
              buckets = vector<Pair *>(capacity, nullptr);
              size = 0;
              // 将键值对从原哈希表搬运至新哈希表
              for (Pair *pair : bucketsTmp) {
                  if (pair != nullptr && pair != TOMBSTONE) {
                      put(pair->key, pair->val);
                      delete pair;
                  }
              }
          }
      
          /* 打印哈希表 */
          void print() {
              for (Pair *pair : buckets) {
                  if (pair == nullptr) {
                      cout << "nullptr" << endl;
                  } else if (pair == TOMBSTONE) {
                      cout << "TOMBSTONE" << endl;
                  } else {
                      cout << pair->key << " -> " << pair->val << endl;
                  }
              }
          }
      };
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
      • 47
      • 48
      • 49
      • 50
      • 51
      • 52
      • 53
      • 54
      • 55
      • 56
      • 57
      • 58
      • 59
      • 60
      • 61
      • 62
      • 63
      • 64
      • 65
      • 66
      • 67
      • 68
      • 69
      • 70
      • 71
      • 72
      • 73
      • 74
      • 75
      • 76
      • 77
      • 78
      • 79
      • 80
      • 81
      • 82
      • 83
      • 84
      • 85
      • 86
      • 87
      • 88
      • 89
      • 90
      • 91
      • 92
      • 93
      • 94
      • 95
      • 96
      • 97
      • 98
      • 99
      • 100
      • 101
      • 102
      • 103
      • 104
      • 105
      • 106
      • 107
      • 108
      • 109
      • 110
      • 111
      • 112
      • 113
      • 114
      • 115
      • 116
      • 117
      • 118
      • 119
      • 120
      • 121
      • 122
      • 123
      • 124
      • 125
      • 126
      • 127
      • 128
      • 129
      • 130
      • 131
      • 132
      • 133
      • 134
    2.2.2 平方探测
    • 平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过 “探测次数的平方” 的步数,即 1, 4, 9, … 步

    • 平方探测优点

      • 平方探测通过跳过平方的距离,试图缓解线性探测的聚集效应
      • 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀
    • 平方探测缺点

      • 仍然存在聚集现象,即某些位置比其他位置更容易被占用
      • 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它
    2.2.3 多次哈希
    • 多次哈希使用多个哈希函数 f_1(x)、f_2(x)、f_3(x)、… 进行探测
      • 插入元素
        • 若哈希函数 f_1(x) 出现冲突,则尝试 f_2(x),以此类推,直到找到空桶后插入元素
      • 查找元素
        • 在相同的哈希函数顺序下进行查找,直到找到目标元素时返回
        • 或当遇到空桶或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 None

    与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会增加额外的计算量

    3. 哈希算法

    无论是开放寻址还是链地址法,它们只能保证哈希表可以在发生冲突时正常工作,但无法减少哈希冲突的发生

    • 如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如下图所示,对于链地址哈希表,理想情况下键值对平均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都被存储到同一个桶中,时间复杂度退化至 O(n)

    在这里插入图片描述

    • 键值对的分布情况由哈希函数决定
      index = hash(key) % capacity // 哈希函数
      
      • 1
      • 当哈希表容量 capacity 固定时,哈希算法 hash() 决定了输出值,进而决定了键值对在哈希表中的分布情况。这意味着,为了减小哈希冲突的发生概率,应当将注意力集中在哈希算法 hash() 的设计上

    3.1 哈希算法的设计

    • 加法哈希
      • 对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值
    • 乘法哈希
      • 利用了乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中
    • 异或哈希
      • 将输入数据的每个元素通过异或操作累积到一个哈希值中
    • 旋转哈希
      • 将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作
      /* 加法哈希 */
      int addHash(string key) {
          long long hash = 0;
          const int MODULUS = 1000000007;
          for (unsigned char c : key) {
              hash = (hash + (int)c) % MODULUS;
          }
          return (int)hash;
      }
      
      /* 乘法哈希 */
      int mulHash(string key) {
          long long hash = 0;
          const int MODULUS = 1000000007;
          for (unsigned char c : key) {
              hash = (31 * hash + (int)c) % MODULUS;
          }
          return (int)hash;
      }
      
      /* 异或哈希 */
      int xorHash(string key) {
          int hash = 0;
          const int MODULUS = 1000000007;
          for (unsigned char c : key) {
              hash ^= (int)c;
          }
          return hash & MODULUS;
      }
      
      /* 旋转哈希 */
      int rotHash(string key) {
          long long hash = 0;
          const int MODULUS = 1000000007;
          for (unsigned char c : key) {
              hash = ((hash << 4) ^ (hash >> 28) ^ (int)c) % MODULUS;
          }
          return (int)hash;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
    • 每种哈希算法的最后一步都是对大质数 1000000007 取模,以确保哈希值在合适的范围内。值得思考的是,为什么要强调对质数取模,或者说对合数取模的弊端是什么?
      • 当使用大质数作为模数时,可以最大化地保证哈希值的均匀分布。因为质数不会与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突
      • 通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期性模式,提升哈希算法的稳健性

    3.2 常见哈希算法

    上述简单哈希算法比较脆弱,比如:由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,并引起一些安全问题

    • 在实际中,通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2、SHA3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值
      • MD5 和 SHA-1 已多次被成功攻击,因此它们被各类安全应用弃用
      • SHA-2 系列中的 SHA-256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常被用在各类安全应用与协议中
      • SHA-3 相较 SHA-2 的实现开销更低、计算效率更高,但目前使用覆盖度不如 SHA-2 系列

    在这里插入图片描述

    3.3 数据结构的哈希值

    • 哈希表的 key 可以是整数、小数或字符串等数据类型。编程语言通常会为这些数据类型提供内置的哈希算法,用于计算哈希表中的桶索引

      int num = 3;
      size_t hashNum = hash<int>()(num);
      // 整数 3 的哈希值为 3
      
      bool bol = true;
      size_t hashBol = hash<bool>()(bol);
      // 布尔量 1 的哈希值为 1
      
      double dec = 3.14159;
      size_t hashDec = hash<double>()(dec);
      // 小数 3.14159 的哈希值为 4614256650576692846
      
      string str = "Hello 算法";
      size_t hashStr = hash<string>()(str);
      // 字符串 Hello 算法 的哈希值为 15466937326284535026
      
      // 在 C++ 中,内置 std:hash() 仅提供基本数据类型的哈希值计算
      // 数组、对象的哈希值计算需要自行实现
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
    • 在许多编程语言中,只有不可变对象才可作为哈希表的 key。假如将列表(动态数组)作为 key ,当列表的内容发生变化时,它的哈希值也随之改变,就无法在哈希表中查询到原先的 value

    • 虽然自定义对象(比如链表节点)的成员变量是可变的,但它是可哈希的。这是因为对象的哈希值通常是基于内存地址生成的,即使对象的内容发生了变化,但它的内存地址不变,哈希值仍然是不变的

  • 相关阅读:
    软件测试--selenium安装使用
    乙炔黑-离子液体复合修饰玻碳电极(AB-ILs/GCE)|离子液体修饰改性的多壁碳纳米管(MWNTs)
    python操作excel中xlrd模块的一些简单方法
    8.3 矢量图层点要素单一符号使用二
    层次分析法(评价类问题)
    导数大题练习
    js加密双重保障之sm2国密
    WebRTC Native M96 基础Base模块介绍之网络相关的封装
    SCADA系统在石油炼制行业的应用:如何实现石油炼制过程实时数据采集与监控?
    ARM pwn 入门 (3)
  • 原文地址:https://blog.csdn.net/qq_42994487/article/details/133623227