哈希表结构

103 阅读7分钟

哈希表结构非常重要,几乎所有的编程语言都直接或间接地应用这种数据结构。它的结构就是数组,但是它神奇的地方在于对下标值的一种变换,这种变换可以称之为哈希函数,通过哈希函数可以获取HashCode

哈希表通常是基于数组实现的,但是相对于数组,他有很多的优势:

  1. 他可以提供非常快速地插入-删除-查找操作;
  2. 无论多少数据,插入和删除只需要接近常量的时间: 即O(1)的时间级。实际上,只需要几个机器指令就可以完成;
  3. 哈希表的速度比树还要快,基本可以瞬间查找到想要的元素;
  4. 哈希表相对于树来说编码要容易很多。

哈希表相对于数组的一些不足:

  1. 哈希表中的数据是没有顺序的,所以不能以一种固定的方式来遍历其中的元素;
  2. 通常情况下,哈希表中的key是不允许重复的,不能放置相同的key, 用于保存不同的元素。

哈希表的一些概念:

  1. 哈希化:将大数字转化成数组范围内下标的过程;
  2. 哈希函数:通常我们会将单词转成大数字(幂等连乘),大数字再进行哈希化的代码实现放在一个函数中,这个函数称为哈希函数;
  3. 哈希表: 最终将数据插入到这个数组,对整个结构的封装,我们称之为哈希表。

面试题:在哈希表中,一旦哈希化后得到的下标值发生冲突,该怎么解决?

  1. 链地址法;
  2. 开放地址法。

链地址法是一种比较常见的解决冲突的方案。

链地址法解决冲突的方法是每个数组单元中存储的不再是单个数据,而是一个链条;

这个链条使用什么数据结构呢?常见的是数组和链表。

比如是链表,一旦发现重复,将重复的元素插入到链表的首端或者末端即可;

当查询时,先根据哈希化后的下标值找到对应的位置,再取出链表,一次性查询需要的数据。

数组还是链表呢?

数组和链表在这里其实都可以,效率上也差不多。

因为根据哈希化后的index找出这个数组或者链表时,通常会使用线性查找,这个时候数组和链表的效率是差不多的;

当然在某些实现中,会将新插入的数据放在数组或者链表的最前面,因为觉得新插入的数据被取出的可能性更大。这种情况最好使用链表,因为数组在首位插入数据是需要其他项后移的,链表就没有这样的问题。

当然,具体如何选择需视业务需求而定。

开放地址法的主要工作方式是寻找空白的单元格来添加重复的数据。

探索这个位置,又有三种方法:
  1. 线性探测
  2. 二次探测
  3. 再哈希法
线性探测即线性探测空白的位置:

插入: 线性探测会从index位置+1开始一点点查找空白的位置来放置新插入的元素;

查询: 先经过哈希化得到index,比较index位置的数值和查询的数值是否相同,相同直接返回;

不同从index位置+1开始线性查找;

注意如果某个位置空白,之前没有插入元素,则停止查询,该要查询的元素不存在。

删除: 删除一个数据时,不可以将这个位置的内容设置为null,否则会影响我们之后查询其他数据。

所以通常删除一个位置的数据项时,我们可以将它进行特殊化处理(比如设置为-1)

之后看到某个位置为-1时,就知道查询时要继续查询,但是插入时这个位置可以放置数据。

问题: 线性探测有一个比较严重的问题就是聚集。

比如在没有任何数据的时候,插入的是23-24-25-26-27,那么意味着下标值为3.4.5..6.7的位置都有元 素。这种一连串填充单元就叫做聚集。

聚集会影响哈希表的性能,无论是插入/查询/删除都有影响。

比如插入一个33,会发现连续的单元都不允许放置数据,并且在这个过程中我们需要探索多次。

二次探测在线性探测的基础上进行了优化:

线性探测可以看作是步长为1的探测,比如index+1,index+2,index+3;

二次探测对步长做了优化,比如index+1^2, index+2^2,index+3^2;

这样就可以一次性探测比较长的距离,以避免那些聚集带来的影响。

问题:如我们插入的事32-112-42-2-82,那么他们一次累加的时候步长是相同的

这种情况会造成步长不一的一种聚集,还是会影响效率(可能性相对连续的数字会小一些)

再哈希法:把关键字用另外一个哈希函数,在做一次哈希化,用这次哈希化的结果作为步长:

二次探测的算法产生的探测序列步长是固定的:1,4,9,16,以此类推;

现在需要一种方法:产生一种依赖关键字的探测序列,而不是每个关键字都一样。

那么,不同的关键字即使映射到相同的数组下标,也可以使用不同的探测序列。

对于指定的关键字,步长在整个探测序列中是不变的,不过不同的关键字使用不同的步长。

第二次哈希化特点:a.和第一个哈希函数不同; b. 不能输出为0,否则每次探测原地踏步。

适用的哈希函数: stepSize = constant - (key % constant)

其中constsnt是质数,且小于数组的容量

例如stepSize = 5 - (key % 5),满足需求,且结果不可能为0.

哈希化的效率:

  1. 哈希表中执行插入和搜索效率是非常高的
  • 如果没有产生冲突,那么效率就会更高
  • 如果产生冲突,存取时间就依赖后来的探测长度
  • 平均探测长度以及平均存取时间,取决于填装因子,随着填装因子变大,探测长度也越来越长
  1. 填装因子表示当前哈希表中已经包含的数据项和整个哈希表长度的比值
  • 填装因子 = 总数据项 / 哈希表长度
  • 开放地址法的填装因子最大为1
  • 链地址法的填装因子可以大于1
  1. 链地址法相对来说效率是优于开放地址法。所以在真实开发中,使用链地址法的情况比较多
  • 因为链地址法不会因为添加了某元素后性能急剧下降
  • 比如在Java的HashMap中使用的就是链地址法。

哈希表的实现:

<script>
    // 结构: [[[k,v],[k,v],[k,v]], [k,v],[k,v],[k,v]]]
    function HashTable() {
      // 属性
      this.storage = []
      this.count = 0
      this.limit = 7
      // 方法
      // 哈希函数
      HashTable.prototype.hashFunc = function (str, size) {
        var hashCode = 0
        for (var i = 0; i < str.length; i++) {
          hashCode = hashCode * 37 + str.charCodeAt(i)   // 37: 取质数
        }
        var index = hashCode % size
        return index
      }
      // 插入&修改操作
      HashTable.prototype.put = function (key, value) {
        var index = this.hashFunc(key, this.limit)
        var bucket = this.storage[index]
        // 判断bucket是否为空
        if (bucket == null) {
          bucket = []
          this.storage[index] = bucket
        }
        for (var i = 0; i < bucket.length; i++) {
          var tuple = bucket[i]
          if (tuple[0] == key) {
            tuple[1] = value
            return
          }
        }
        bucket.push([key, value])
        this.count += 1
        if(this.count > this.limit * 0.75){   // 考虑扩容
          var newSize  = this.limit * 2
          var newPrime = this.getPrime(newSize)
          this.resize(newPrime)
        }
      }
      // 获取元素操作
      HashTable.prototype.get = function (key) {
        var index = this.hashFunc(key, this.limit)
        var bucket = this.storage[index]
        if (bucket == null) return null
        for (var i = 0; i < bucket.length; i++) {
          var tuple = bucket[i]
          if (tuple[0] == key) return tuple[1]
        }
        return null
      }
      // 删除操作
      HashTable.prototype.delete = function (key) {
        var index = this.hashFunc(key, this.limit)
        var bucket = this.storage[index]
        if (bucket == null) return null
        for (var i = 0; i < bucket.length; i++) {
          var tuple = bucket[i]
          if (tuple[0] == key) {
            bucket.splice(i, 1)
            this.count--
            if(this.limit > 7 && this.count < this.limit * 0.25){   // 考虑减容
              var newSize = Math.floor(this.limit / 2)
              var newPrime = this.getPrime(newSize)
              this.resize(newPrime)
            } 
            return tuple[1]
          }
        }
        return null
      }
      // 哈希表的扩容
      HashTable.prototype.resize = function (newLimit){
        var oldStorage = this.storage
        this.storage = []
        this.count = 0
        this.limit = newLimit
        for(var i = 0; i<oldStorage.length; i++){
          var bucket  = oldStorage[i]
          if(bucket == null){
            continue
          }
          for(var j = 0; j<bucket.length; j++){
            var tuple = bucket[j]
            this.put(tuple[0], tuple[1])
          }
        }
      } 
      // 判断是否为质数 
      HashTable.prototype.isPrime = function (num) {
        var temp = parseInt(Math.sqrt(num))
        for (var i = 2; i <= temp; i++) {
          if (num % i == 0) {
            return false
          }
          return true
        }
      }
      // 获取质数
      HashTable.prototype.getPrime =function(num){
        while(!this.isPrime(num)){
          num++
        }
        return num
      }
    }
  </script>

哈希表扩容的思想

为什么需要扩容:

  • 以上实现哈希表使用的是链地址法,loadFactor可以大于1, 所以这个哈希表可以无限的插入新数据;
  • 但是,随着数据的增多,每一个index对应的bucket会越来越长,也就造成效率的降低;
  • 所以,在合适的情况对数组进行扩容是必要的。

如何进行扩容:

  • 扩容可以简单地将容量增大两倍(后面在考虑质数问题)
  • 但在扩容时,所有数据项一定要同时进行修改(重新调用哈希函数,获取新的index)
  • 这是一个耗时的过程,但如果数组扩容,这个过程是必要的。

什么情况下扩容:

  • 比较常见的情况就是loadFactor>0.75时进行;
  • 比如Java的哈希表就是在填装因子大于0.75时进行扩容。