第三章 查找(四) - 《算法》读书笔记

第三章 查找(四)

3.4 散列表

3.4.1 散列函数

  • 散列函数的计算将键转化为数组的索引

    • 如果我们有一个能够保存M个键值对的数组,那么我们就需要一个能够将任意键转化为该数组范围内的索引的散列函数
    • 易于计算,并且能够均匀分布所有的键,即0到M-1之间的每个整数都能有相等的可能性与之对应
  • 对于每种类型的键,我们都需要一个与之对应的散列函数

3.4.1.2 正整数

  • 将整数散列最常用的方法是除留余数法
  • 如果M不是素数,我们可能无法利用键中包含的所有信息

3.4.1.3 浮点数

  • 如果键是0到1之间的实数,可以将它乘以M并四舍五入得到一个0至M-1之间的索引值
    • 这种情况下高位起的作用较大
  • 解决方法:将键表示为二进制数,再使用除留余数法

3.4.1.4 字符串

  • 可以当做大整数,使用除留余数法
int hash = 0;
for(int i = 0; i < s.length(); i++)
    hash = (R * hash + s.charAt(i)) % N;
  • 如果R比任何字符的值都大,就相当于将字符串视为一个N位的R进制值

3.4.1.5 组合键

  • 如果键的类型含有多个整型变量,可以和String类型一样将它们混合起来

3.4.1.6 Java的约定

  • 所有数据类型都继承了一个能够返回一个32bit整数的hashCode()方法,且必须和equals()方法一致

3.4.1.7 将hashCode()的返回值转化为一个数组索引

  • 结合除留余数法

3.4.1.9 软缓存

  • 将每个键的散列值缓存起来

  • 优秀的散列方法需要满足三个条件:

    • 一致性:等价的键必然产生相等的散列值
    • 高效性:计算简便
    • 均匀性:均匀地散列所有的键

均匀散列假设:我们使用的散列函数能够均匀并独立地将所有的键散布于0到M-1之间。

3.4.2 基于拉链法的散列表

  • 散列算法的第二步是碰撞处理

  • 拉链法:将大小为M的数组中的每个元素指向一条链表,链表中的每个结点都存储了散列值为该元素的索引的键值对

  • 具体实现如下:(SequentialSearchST对象实现了基于无序链表的顺序查找)

public class SeparateChainingHashST<Key, Value>{
    private int N;
    private int M;
    private SequentialSearchST<Key, Value>[] st;
    
    public SeparateChainingHashST(){
        this(997);
    }
    public SeparateChainingHashST(int M){
        this.M = M;
        st = (SequentialSearchST<Key, Value>[]) new SequentialSearchST[M];
        for(int i = 0; i < M; i++)
            st[i] = new SequentialSearchST();
    }
    
	private int hash(Key key){
        //忽略符号位,使用除留余数法
        return (key.hashCode() & 0x7fffffff) % M;
    }
    private Value get(Key key){
        return (Value) st[hash(key)].get(key);
    }
    public void put(Key key, Value val){
        st[hash(key)].put(key, val);
    }
    public Iterable<Key> keys();
}

在一张含有M条链表和N个键的散列表中,任意一条链表中的键的数量均在N/M的常数因子范围内的概率无限趋向于1。

在一张含有M条链表和N个键的散列表中,未命中查找和插入操作所需的比较次数为~N/M、

3.4.3 基于线性探测法的散列表

  • 开放地址散列表:用大小为M的数组保存N个键值对,其中M>N。我们需要依靠数组中空位解决碰撞冲突。

  • 线性探测法:当碰撞发生时,直接检查散列表中的下一个位置,会产生三种结果:

    • 命中,该位置的键和被查找的键相同
    • 未命中,键为空
    • 继续查找,该位置的键和被查找的键不同
  • 我们习惯将检查一个数组是否含有被查找的键的操作称作探测,等价于我们一直使用的比较

  • 具体实现如下:

public class LinearProbingHashST<Key, Value>{
    private int N;
    private int M;
    private Key[] keys;
    private Value[] vals;
    
    public LinearProbingHashST(){
        keys = (Key[]) new Object[M];
        vals = (Value[]) new Object[M];
    }
    
    private int hash(Key key){
        return (key.hashCode() & 0x7fffffff) % M;
    }
    public void resize();
    
    public void put(Key key, Value val){
        if (N >= M/2) resize(2*M);
        int i;
        for(i = hash(Key); keys[i] != null; i = (i + 1) % N)
            if(keys[i].equals(key)){
                vals[i] = val;
                return;
            }
        keys[i] = key;
        vals[i] = val;
        N++;
    }
    public Value get(Key key){
        for(int i = hash(Key); keys[i] != null; i = (i + 1) % N)
            if(keys[i].equals(key))
                return vals[i];
        return null;
    }
}

3.4.3.1 删除操作

  • 为了不影响后续的查找,我们需要将簇中被删除键的右侧所有的键重新插入散列表
  • 我们称α=N/M为散列表的使用率
    • 对于基于拉链法的散列表,α一般大于1
    • 对于线性探测的散列表,α不可能大于1

3.4.3.2 键簇

  • 线性探测的平均成本取决于元素在插入数组后聚集成的一条连续的条目,也叫作键簇

3.4.3.3 线性探测法的性能分析

在一张大小为M并含有N=αM个键的基于线性探测的散列表中,命中和未命中的查找所需的探测次数分别为1/2·(1+(1-α))和1/2·(1+(1-α)^2),特别是当α为1/2时,查找命中所需要的的探测次数约为3/2,未命中所需要的时间约为5/2.当α趋近于1时,这些估计值的精确度会下降,但不需要担心这些情况,因为我们会保证散列表的使用率小于1/2。

3.4.4 调整数组大小

3.4.4.2 均摊分析

假设一张散列表能够自己调整数组的大小,初始为空。那么执行任意顺序的t次查找、插入和删除操作所需的时间和t成正比,所使用的内存量总是在表中的键的总数的常数因子范围内。

3.4.5 内存使用

  • 符号表的内存使用情况如下:
方法 N个元素所需的内存(引用类型)
基于拉链法的散列表 ~48N+32M
基于线性探测的散列表 32N到128N之间
各种二叉查找树 ~56N
  • 散列表并非包治百病的灵丹妙药,因为:
    • 每种类型的键都需要一个优秀的散列函数
    • 性能保证来自于散列函数的质量
    • 散列函数的计算可能复杂而且昂贵
    • 难以支持有序性相关的符号表操作
posted @ 2021-02-02 23:45  一天到晚睡觉的鱼  阅读(111)  评论(0)    收藏  举报