javascript数据结构 -- 哈希表(四)

195 阅读9分钟

哈希表

本文在前文的基础上,通过开发地址方法和线性搜索算法实现哈希表。使用开发地址方法实现哈希表总体上看来难度远高于使用链地址方法,主要体现在插入、删除元素的时候的坑比较多。

0. 处理下标

开发地址法和链地址法的一个根本性的不同在于:

  • 链地址法通过哈希函数计算然后收缩的下标值找到桶(bucket),然后只需要遍历这个桶就可以完成新元素的插入或者旧元素的删除了;
  • 而开放地址法中没有桶,哈希表的下标中放的直接就是元素,这样就有一个难点--如何处理下标冲突问题;
  • 如果计算出来的下标值为5,那么很有可能5之后没有空位,但是5之前刚好有一个,这就要求线性搜索的时候如果搜到了最后一个位置,就应该从头搜索然后在下标4处停下来,以保证线性搜索过程不重不漏;

"加"然后"取余"

请注意这样的一个事实:假如x为大于2的正整数,则使用下面的程序可以对长度为x的数组的下标循环扫描

const arrLen = 20;
let currentIndex = 5;
while (1) {
    console.log((currentIndex + arrLen) % arrLen);
    currentIndex++;
}

但是在线性探索方法中,没有必要对数组下表进行循环扫描,只需要扫描一周即可,因此:

const arrLen = 20;
let currentIndex = 5;
while (currentIndex !== currentIndex + arrLen - 1) {
    console.log((currentIndex + arrLen) % arrLen);
    currentIndex++;
}

加然后取余:(currentIndex + arrLen) % arrLen

此外,扫描下标一周的循环终止条件也很重要:currentIndex !== currentIndex + arrLen - 1

1. 插入元素

使用开放地址方法和线性探索方法实现哈希表的时候,插入元素的策略如下:

  • ..1. 使用哈希函数计算然后收紧得到下标值index
  • ..2. 计算得到扫描哈希表一周对应的的终止下标endPosition = index + 哈希表容量;
  • ..3. 使用while循环扫描哈希表的所有下标;
  • ..4. 在每一个循环周期内做如下判断:
      1. 根据线性增大的循环因子计算出对应的下标,循环因子用index表示,则对应的下标值为:computedIndex = index % 哈希表容量
      1. 根据计算出来的computedIndex找到哈希表中对应位置的元素:_tmp = storage[computedIndex]; 多说一句,由于storage[computedIndex]是一个中间量,它的【值得重要程度】远远大于其在结构中的重要程度,所以使用以下划线开头的变量存储其值;
      1. 对_tmp的值进行分类讨论:
      • 倘若_tmp为假值,证明此位置还没有插入过,此时将元素直接插入即可,然后将哈希表的有效程度+1,然后直接结束循环即可;

      • 倘若_tmp不为假值,则说明此位置已经被占领,这又可以分两种情况:

        a. _tmp的key和即将插入的元素的key相同,这个使用首先判断_tmp的deleted是不是为false:

         如果是false,则属于更新已有元素的值,这个时候将新元素的值赋值给旧元素即可,哈希表的有效长度不需要增加,直接跳出循环即可;
         如果_tmp的deleted值为true,表示这个元素之前插入过,然后又被删除了,这个时候需要:1. 将deleted值改为false; 2. 将新值更新上去; 3. 将哈希表的有效长度增加1; 4. 判断哈希表是否需要扩容,如果需要则进行扩容。
        

        b._tmp的key和即将插入的元素的key不同,表示此位置已经被其他元素占领,这个时候什么都不需要做,等待下一次循环即可。

是不是相当复杂,但这还好,只需要理清楚就可以了;真正坑的点在于:

  1. javascript中传值不传址,有时候可能会想当然的进行赋值,但实际上没有生效;
  2. 哈希表中的count的含义是有效元素的个数,不包含已经被标记删除的元素,所以count的值变化的时机,需要有清晰的区分。

2. 获取元素

获取元素比起插入元素要简单一些,不需要改变有效元素的个数,规则如下:

  • ..1. 使用哈希函数计算然后收紧得到下标值index
  • ..2. 计算得到扫描哈希表一周对应的的终止下标endPosition = index + 哈希表容量;
  • ..3. 使用while循环扫描哈希表的所有下标;
  • ..4. 在每一个循环周期内做如下判断:
      1. 根据线性增大的循环因子计算出对应的下标,循环因子用index表示,则对应的下标值为:computedIndex = index % 哈希表容量
      1. 根据计算出来的computedIndex找到哈希表中对应位置的元素:_tmp = storage[computedIndex];
      1. 对_tmp的值进行分类讨论:
      • 倘若_tmp为假值,证明此位置还没有插入过,那么想要get的key也不会出现在其他下标位置,直接返回null表示没有找到;

      • 倘若_tmp不为假值,则说明此位置已经被占领,这又可以分两种情况:

        a. _tmp的key和即将插入的元素的key相同,这个使用首先判断_tmp的deleted是不是为false:

         如果是false,表示元素存在并且没有被标记删除,直接返回此元素即可;
         如果_tmp的deleted值为true,表示这个元素之前插入过,然后又被删除了,这个时候直接返回null即可。
        

        b._tmp的key和即将插入的元素的key不同,表示此位置已经被其他元素占领,这个时候什么都不需要做,等待下一次循环即可。

3. 删除元素

删除元素的复杂程度和插入元素基本相同,对应的策略如下:

  • ..1. 使用哈希函数计算然后收紧得到下标值index
  • ..2. 计算得到扫描哈希表一周对应的的终止下标endPosition = index + 哈希表容量;
  • ..3. 使用while循环扫描哈希表的所有下标;
  • ..4. 在每一个循环周期内做如下判断:
      1. 根据线性增大的循环因子计算出对应的下标,循环因子用index表示,则对应的下标值为:computedIndex = index % 哈希表容量
      1. 根据计算出来的computedIndex找到哈希表中对应位置的元素:_tmp = storage[computedIndex];
      1. 对_tmp的值进行分类讨论:
      • 倘若_tmp为假值,证明此位置还没有插入过,要删除的元素也不会出现在其它下标中,这个时候直接返回null就可以了;

      • 倘若_tmp不为假值,则说明此位置已经被占领,这又可以分两种情况:

        a. _tmp的key和即将插入的元素的key相同,这个使用首先判断_tmp的deleted是不是为false:

         如果是false,表示元素存在且没有被删除,这个时候将此元素标记为删除,将哈希表中的有效元素个数减1,然后返回此元素即可;
         如果_tmp的deleted值为true,表示这个元素之前插入过,然后又被删除了,这个时候直接返回null表示没有删除成功即可。
        

        b._tmp的key和即将插入的元素的key不同,表示此位置已经被其他元素占领,这个时候什么都不需要做,等待下一次循环即可。

4. 扩容和缩容

和链地址方法不同之处在于:

  • 开放地址方法中没有bucket,所以只需要遍历storage一层即可;
  • 遍历元素并将其插入到新的storage的时候,需要判断元素是不是被标记删除了,如果已经标记删除了,是不需要插入到新的storage中去的。

5. 实现代码

/**
 * 要解决的问题是删除元素的行为,不能和链地址方法一样直接删掉,而是要标记删除
 */

type IElement = {
  key: string;
  value: any;
  deleted: boolean;
}

class _Hash {
  storage: Array<IElement> = [];
  count = 0;
  limit = 7; // 容量取质数,但是对于链地址法要求不严格

  // 用来产生哈希值的哈希函数
  hashFunc (str: string, size: number) {
      let hashcode = 0; // 哈希函数计算值
      // 使用霍纳算法计算hashcode
      for (let i = 0; i < str.length; i++) {
          hashcode = hashcode * 37 + str.charCodeAt(i);
      }
      // 下标收紧至数组容量范围内
      const index = hashcode % size;
      return index;
  }

  // 向storage中增加新的元素
  put (key: string, value: any) {
      // 使用哈希函数计算出下标之后收紧
      let index = this.hashFunc(key, this.limit);
      const endPosition = index + this.limit;
      // 循环找一圈
      while(index !== endPosition){
        // 使用计算得到的下标值找到storage此下标的数组
        // 注意这里有一个圈
        const computedIndex = index % this.limit;
        let _tmp = this.storage[computedIndex];
        // 插入新的元素
        if(!_tmp){
          this.storage[computedIndex] = {
              key, 
              value, 
              deleted: false
            };
            // 插入新元素之后不要忘记更新长度
            this.count++;
            // 判断是否需要扩容
            if(this.count > this.limit * 0.75) this.resize(this.limit * 2);
            break;
        } else if(_tmp.key === key){
          // 这种情况是修改原有的元素
          this.storage[computedIndex].value = value;
          // 如果是修改,则count的值不变
          if(this.storage[computedIndex].deleted){
            // 如果是修改已经被删除的,则哈希表的有效长度需要发生改变
            this.storage[computedIndex].deleted = false;
            // 因为count表示的是有效元素的数目,所以这里也要++
            this.count++;
          }
          break;
        } else {
          index++;
        }
      }

      // 处理没有空位的情况: 先进行扩容,然后再插入此数据
      if(index === endPosition){
        this.resize(this.limit * 2);
        this.put(key, value);
      }
  }

  // 通过key获取storage中的元素
  get (key: string): null | IElement{
      // 使用哈希函数计算出下标之后收紧
      let index = this.hashFunc(key, this.limit);
      const endPosition = index + this.limit;
      // 循环找一圈
      while(index !== endPosition){
        // 使用计算得到的下标值找到storage此下标的数组
        // 注意这里有一个圈
        const computedIndex = index % this.limit;
        let _tmp = this.storage[computedIndex];
        // 如果_tmp是空的,则可以立即判断为未插入
        if(!_tmp){
            return null
        } else if(_tmp.key === key){
          // 如果key对上,不能直接返回,而是先判断是否被标记删除
          if(_tmp.deleted) return null;
          // 确认没有被删除的话就将其返回
          return _tmp;
        } else {
          // 如果没有找到,那就下一个
          index++;
        }
      }
      // 转一圈还没找到,那就返回null
      return null;
  }

  // 通过key删除某个元素:不是真的删除,而是将其标记删除
  remove (key: string): null | IElement {
      // 使用哈希函数计算出下标之后收紧
      let index = this.hashFunc(key, this.limit);
      const endPosition = index + this.limit;
      // 循环找一圈
      while(index !== endPosition){
        // 使用计算得到的下标值找到storage此下标的数组
        // 注意这里有一个圈
        const computedIndex = index % this.limit;
        let _tmp = this.storage[computedIndex];
        if(!_tmp){
            // 这种情况表示不存在这个key对应的元素
            return null; // 表示删除失败
        } else if(_tmp.key === key){
          // 这种情况要先判断是否已经删除,如果已经删除直接continue
          if(_tmp.deleted) continue;
          // 如果deleted依然是false则表示可以标记删除,返回删除的元素
          this.storage[computedIndex].deleted = true;
          this.count--;
          // 开发地址方法和链地址方法计算负载因子的思路是一样的
          // this.count--和标记删除或真正删除没有关系,因为this.count表示的是哈希表中的有效元素的个数
          // 判断是否需要缩容(这里保证了一个最小长度)
          if(this.limit > 7 && this.count < this.limit * 0.25) this.resize(Math.floor(this.limit / 2));
          return _tmp;
        } else {
          index++;
        }
      }
      // 如果遍历一圈还没有找到,那就直接返回null表示删除失败
      return null;
  }

  // 判断哈希表是否为空
  isEmpty (): boolean {
      return this.count === 0;
  }

  // 返回哈希表中的数目
  size (): number {
      return this.count;
  }

  // 哈希表改变容量大小
  resize (newLimit: number): void {
      // 暂存原始数据(由于遍历的时候不需要用到count和limit所以只需要存thie.storage就可以了)
      const oldStorage = this.storage;
      // 格式化数据
      this.storage = [];
      this.count = 0;
      this.limit = newLimit;
      // 遍历原来哈希表中的每一个元素,逐个插入到新的哈希表中去
      oldStorage.forEach(
          _tmp => {
              // 如果_tmp为真并且其中的元素的deleted值不为true则将这个元素插入到新的哈希表中去
              if (_tmp) {
                const {key, value, deleted} = _tmp;
                if(!deleted){
                  this.put(key, value);
                }
              }
          }
      )
  }

  // 判断是否为质数
  isPrime (limit: number): boolean {
      const _tmp = ~~Math.sqrt(limit);
      for (let index = 2; index < _tmp; index++) {
          if( limit % index === 0 ) return false;
      }
      return true;
  }

  // 找到附近的质数
  findPrime (nearby: number): number {
      let prime = nearby;
      while(!this.isPrime(prime)){
          prime++;
      }
      return prime;
  }
}

6. 测试

const h = new _Hash();
h.put('a', 132);
h.put('a', 700);
h.remove('a');
console.log(h.size()); // 0
console.log(h.isEmpty()); // true
h.put('a', 700);
h.put('b', 700);
h.put('c', 700);
h.put('d', 700);
h.put('e', 700);
h.put('f', 700);
h.put('g', 700);
h.put('h', 700);
console.log(h.size()); // 8
console.log(h.get('p')); // null
console.log(h.get('a')); // {key: 'a', value: 700, deleted: false}
h.remove('a');
h.remove('b');
h.remove('c');
h.remove('d');
h.remove('e');
h.remove('f');
h.remove('g');
console.log(h.size()); // 1