哈希表

Mr.Hdd数据结构哈希表大约 7 分钟...

「哈希表 Hash 」

哈希表的基本术语

「哈希表 Hash」又称散列表;其思想与离散化类似,对于复杂信息的统计,可以使用 HashHash 函数将这些复杂信息映射到容易维护的值域内。然后由于值域变简单,范围变小,所以可能造成两个不同的原始信息被 HashHash 函数映射为相同的值,这被称为「哈希冲突」,所以需要解决冲突,常用的方法有两种

  1. 拉链法
  2. 开放寻址法

哈希表的基本操作:

  1. 计算 HashHash 函数的值;
  2. 根据处理冲突的策略执行与 HashHash 函数的值执行插入操作。
  3. 查找操作
  4. 删除操作

Hash 函数的设计

1.直接定址法

是关于「键」的线性函数,即:

H(key)=a×key+b H(key)=a × key + b

使用线性函数的好处是不会产生冲突,但是这只适用于「键」的分布,并且「键」的集合不是很大且连续的情况下使用较好。

2.除留余数法

基本思想:选择某个适当的正整数 pp ,以「键」除以 pp 的余数作为哈希值。

H(key)=keymod  p H(key) = key \mod \ \ p

可以看出这个哈希函数的关键在于 pp ,对 pp 的选择通常是选择 N\le N 的最大质数(特别地,这不是 22 的幂)。(NN 为需要哈希的数据量,也即哈希表的容量)。使用除留余数法时不需要已知「键」的分布;且其结构较为简单,是较为常用哈希函数。

注意

在运算规则中,任何数 mod\bmod 正整数的结果都 0\ge 0 的,但是在编码中,负数 mod\bmod 正整数的结果可能为负数,所以在编码中可做这样处理:

Kidx=(Vidx % p+p ) % p K_{idx} = (V_{idx} \ \% \ p + p \ )\ \% \ p

或者使用位运算直接将符号为屏蔽:(将一个 3232 位的整数变为一个 3131 的非负整数)

Kidx=(Vidx  &  0x7fffffff) % p K_{idx} = (V_{idx} \ \ \& \ \ 0x7fffffff)\ \% \ p

就可以保证键的索引非负了。

哈希函数的启发式规则:越是随机,越是没有规律,就越是好的哈希函数。

相关信息

在上述的哈希函数中,都对 「键 key」作数值计算,而在实际应用中,「键」不一定是数字,可能是字符串,也可能是一个类,在 rust 中提供了 Hash trait , 在 java 中每个类都具有 hashCode() 方法,目的就是让每种数据都需要有相应的哈希函数,那么就需要对这些不规则的「键」进行转换,转换为数字,以便于哈希函数的计算。

拉链法

首先对于哈希表来说,都需要一个「哈希槽」来存储 (key, value) 中的 value,即一个一维数组;而拉链法处理冲突的策略如下图:

image-20230220104035861

将冲突的值存储在链表中,当通过 HashHash 函数计算后,若对应的值有冲突,则对所在链表再做一次查询即可。


开放寻找法

开放寻址法的解决冲突的策略如下图:也即遇到冲突时继续向后寻找,若向后寻找过程一直有冲突,则寻找到末尾时,从头开始继续寻找;然后向后寻址的方式也有多种,例如线性探测,二次探测等等,下图为线性探测:

image-20230220111044525

二次探测法

(H(key)+di) % m (H(key) + d_i) \ \% \ m

线性探测法中的 di=1,2,3,...,N1d_i=1,2,3,...,N-1 ;而二次探测法中的 di=12,12,22,22,...,q2,q2d_i=1^2,-1^2,2^2,-2^2,...,q^2,-q^2 ,其 qNq \le \sqrt{N}

struct HashTable {
  h: Vec<isize>,
  hash_func: fn(&isize)->usize,
}

impl  HashTable  {
  /// 初始化
  pub fn new(m: usize,hash_func: fn(&isize)->usize) -> Self {
    Self { h: vec![isize::MAX; m], hash_func }
  }
  /// 寻找索引 O(1)
  pub fn find_idx(&mut self ,x: isize) -> usize {
    let mut idx = (self.hash_func)(&x);
    while self.h.get(idx) != Some(&x) && self.h.get(idx) != Some(&isize::MAX) {
      idx += 1;
      if idx == self.h.len() { idx = 0 }
    }
    idx
  }
  /// 插入元素 O(1)
  pub fn insert(&mut self , x: isize) {
    let idx = self.find_idx(x);
    self.h[idx] = x;
  }
  /// 寻找元素 O(1)
  pub fn find(&mut self, x: isize) -> bool {
    let idx = self.find_idx(x);
    if self.h[idx] != isize::MAX { true }
    else { false }
  }
}

删除操作

在开放寻址法处理冲突的情况下,若要进行删除操作,需注意:

  1. 删除一个记录一定不能影响后续的查找操作;
  2. 删除记录后的存储单元能够为将来的插入所使用。

所以较好的解决方式是采用懒惰删除,也即在被删除记录的位置上进行标记,比如使用 bool 变量标记等等。

散列查找

用开放寻址法处理冲突的散列表被称为闭散列表,用拉链法处理的被称为开散列表

「堆积 mass」 :是指在闭散列表中,可能会出现不同散列值的「值」对同一地址的争夺。显然,堆积明显降低了查找效率。

开散列表和闭散列表的区别:

开散列表闭散列表
拉链法开发寻址法
不会产生「堆积」可能产生「堆积」
各项操作易于实现删除操作不易实现,使用懒惰删除
平均查找长度较短平均查找长度较长
需要附加指针,增加了存储开销无需附加指针,空间效率高
由于链表是可以动态增长的,所以可以不确定散列表的容量必须提前估计容量并分配固定大小容量的空间

性能分析

使用散列表进行查找操作时,其时间效率几乎可以达到 O(1)O(1) ,并且不需要排序或其他操作。影响散列查找的的三个关键因素如下:

  1. 散列函数是否均匀: 散列函数的是否均匀会直接影响产生冲突的效率,所以在选择散列函数时,需要尽可能地选择分布均匀的散列函数;
  2. 处理冲突的方法: 好的处理冲突的方法可以提高查找效率,不过没有万能的处理方式,只有在不同情况下选择不同的处理方法才是高效的;
  3. 散列表的「装填因子 load factor」:设填入散列表中的记录个数为 nn ,散列表长为 mm ,则装填因子 α=n/m\alpha=n / m 。其标志着散列表的装满程度,由于表长是定值,那么 α\alpha 就和填入散列表中的记录个数成正比。

「平均查找长度 ASL」是对散列表查找效率的度量,并且是装填因子 α\alpha 的函数,不同处理冲突的方法有不同的函数:

查找成功时的 ASL查找失败时的 ASL
线性探测法12(1+11α)\frac{1}{2}(1+\frac{1}{1-\alpha})12(1+1(1α)2)\frac{1}{2}(1+\frac{1}{(1-\alpha)^2})
二次探测法1αln(1+α)-\frac{1}{\alpha}ln(1+\alpha)11α\frac{1}{1-\alpha}
拉链法1+α21+\frac{\alpha}{2}α+eα\alpha + e^{-\alpha}

不管 nn 有多大,总可以选择一个合适的装填因子以将 ASLASL 限定在一个范围内,也即其时间复杂度几乎是 O(1)O(1)

注意

为保证空间效率,一般建议保持 0.9>α>0.50.9> \alpha > 0.5 ,也即散列表中半满的。

你认为这篇文章怎么样?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.14.9