算法-17-散列表

 

目录

1、散列表

2、散列函数

2.1、整数-除留余数法

2.2、浮点数

2.3、字符串

2.4、Java-hashcode()

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

3、软缓存

4、解决哈希冲突1-拉链法

5、解决哈希冲突2-线性探测法

5.1、删除操作

5.2、键蔟

5.3、调整数组大小

6、内存使用

7、拉链法与线性探测法比较

8、各种符号表性能比较


1、散列表

散列表其实就是用一个数组来存储我们的键值对,在存储过程中,我们会将key通过散列函数将转换为数组的索引index,然后将value存储在数组的该索引下。

当然我们在存储过程中会遇到不同的key通过相同的散列函数转换成的索引index是相同的情况,这时候我们就需要解决这种情况,这个也叫做哈希冲突,这里我们将讲两种解决方法:拉链法线性探测法

散列表是算法在时间和空间上作出权衡的经典例子。

缺点:无法进行有序性相关的操作,比如:获取最大,最小值。

2、散列函数

散列函数的作用就是将key转换成数组的索引。如果我们有 一个能够保存M个键值对的数组,那么我们就需要一个能够将任意键转化为该数组范围内的索引([0, M-1]范围内的整数)的散列函数

2.1、整数-除留余数法

将整数散列最常用方法是除留余数法。我们选择大小为素数M的数组, 对于任意正整数k,计算k除以M的余数。

选择素数的原因是可以比较均匀地散列数组。

2.2、浮点数

如果键是0到1之间的实数,我们可以将它乘以M并四舍五入得到一个0至M-1之间的索引值。 尽管这个方法很容易理解,但它是有缺陷的,因为这种情况下键的高位起的作用更大,最低位对散 列的结果没有影响。修正这个问题的办法是将键表示为二进制数然后再使用除留余数法(Java 就是 这么做的)。 

2.3、字符串

2.4、Java-hashcode()

每种数据类型都需要相应的散列函数,于是 Java 令所有数据类型都继承了一个能够返回一个 32 比特整数的 hashCode() 方法。

默认散列函数会返回对象的内存地址, 但这只适用于很少的情况。Java 为很多常用的数据类型重写了 hashCode() 方法(包括 String、 Integer、Double、File 和 URL)。 

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

因为我们需要的是数组的索引而不是一个 32 位的整数,我们在实现中会将默认的 hashCode() 方法和除留余数法结合起来产生一个 0 到 M-1 的整数,方法如下:

private int hash(Key x){  
    return (x.hashCode() & 0x7fffffff) % M; 
 } 

3、软缓存

如果散列值的计算很耗时,那么我们或许可以将每个键的散列值缓存起来,即在每个键中使用 一个 hash 变量来保存它的 hashCode() 的返回值。第一次调用 hashCode() 方 法时,我们需要计算对象的散列值,但之后对 hashCode() 方法的调用会直接返回 hash 变量的值。 Java 的 String 对象的 hashCode() 方法就使用了这种方法来减少计算量。

总的来说,要为一个数据类型实现一个优秀的散列方法需要满足三个条件:

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

4、解决哈希冲突1-拉链法

当然我们在存储过程中会遇到不同的key通过相同的散列函数转换成的索引index是相同的情况,这时候我们就需要解决这种情况,这个也叫做哈希冲突,第一种解决哈希冲突的方法是:拉链法。

拉链法的思想就是:我们将13个数据,存储在一个大小为5的数组中, 首先我们通过散列函数将它们的key转换成数组索引,这时候肯定有重复的索引,我们会讲重复的索引通过链表的形式将它们存储起来,如下图:

这段简单的符号表实现维护着一条链表的数组,用散列函数来为每个键选择一条链表。简单起见,我们使用了 SequentialSearchST。在创建 st[] 时需要进行类型转换,因为 Java 不允许泛型的数组。 默认的构造函数会使用 111 条链表,因此对于较大的符号表,这种实现比 SequentialSearchST 大约 会快 1000 倍。当你能够预知所需要的符号表的大小时,这段短小精悍的方案能够得到不错的性能。一种 更可靠的方案是动态调整链表数组的大小,这样无论在符号表中有多少键值对都能保证链表较短。

public class SeparateChainingHashST<Key,Value> {

    private int M;//散列表的数组大小
    private int N;//存放键值对的实际数量
    private SequentialSearchST<Key,Value>[] st;//存储链表对象的数组

    public SeparateChainingHashST() {
        this(111);
    }
    private SeparateChainingHashST(int m) {
        this.M = m;
        st=(SequentialSearchST<Key,Value>[]) new Object[M];

        for (int i=0;i<M;i++){
            st[i]=new SequentialSearchST<>();
        }
    }
    //散列函数,将key转换成数组st索引
    public int hash(Key key){
        return (key.hashCode()&0x7fffffff)%M;
    }
    
    public void put(Key key,Value value){
        st[hash(key)].put(key,value);
    }
    
    public Value get(Key key){
      return   st[hash(key)].get(key);
    }
    
}
public class SequentialSearchST<Key, Value> {
    private Node first;
    private int N = 0;

    private class Node {
        Key key;
        Value val;
        Node next;

        public Node(Key key, Value val, Node next) {
            this.key = key;
            this.val = val;
            this.next = next;
        }
    }

    public Value get(Key key) {
        for (Node x = first; x != null; x = x.next) {
            if (key.equals(x.key)) {
                return x.val;
            }
        }
        return null;
    }

    public void put(Key key, Value val) {
        for (Node x = first; x != null; x = x.next) {
            if (key.equals(x.key)) {
                x.val = val;
                return;
            }
        }
        first = new Node(key, val, first);
        N++;
    }

    // Exercise 3.1.5
    public int size() {
        return N;
    }

    public void delete(Key key) {
        first = delete(first, key);
    }

    private Node delete(Node x, Key key) {
        if (x == null) {
            return null;
        }
        if (x.key.equals(key)) {
            N--;
            return x.next;
        }
        x.next = delete(x.next, key);
        return x;
    }

    public Iterable<Key> keys() {
        Queue<Key> queue = new Queue<>();
        for (Node x = first; x != null; x = x.next) {
            queue.enqueue(x.key);
        }
        return queue;
    }
}

5、解决哈希冲突2-线性探测法

实现散列表的另一种方式就是用大小为 M 的数组保存 N 个键值对,其中 M>N。我们需要依靠 数组中的空位解决碰撞冲突。基于这种策略的所有方法被统称为开放地址散列表

开放地址散列表中最简单的方法叫做线性探测法:当碰撞发生时(当一个键的散列值已经被另 一个不同的键占用),我们直接检查散列表中的下一个位置(将索引值加 1)。这样的线性探测可 能会产生三种结果:

  • 命中,该位置的键和被查找的键相同;
  • 未命中,键为空(该位置没有键);
  • 继续查找,该位置的键和被查找的键不同。

我们用散列函数找到键在数组中的索引,检查其中的键和被查找的键是否相同。如果不同则继续查找(将索引增大,到达数组结尾时折回数组的开头),直到找到该键或者遇到一个空元素。

插入的操作也需要线性探测,比如数组a={0,0,B,D,R,0,0},这时候我们插入的key=C转换后的索引是2的话,我们不能将B替换而是在它的后面查找,如果后面有C我们就替换,如果没有,我们会将C插入到R的后面(第一个空位的位置)。

开放地址类的散列表的核心思想是与其将内存用作链表,不如将它们作为在散列表的空元素。 这些空元素可以作为查找结束的标志。

public class LinearProbingHashST<Key, Value> {

    private int N;//存储键值对的实际数量
    private int M;//数组大小
    private Key[] keys;
    private Value[] values;

    public LinearProbingHashST(int m) {
        M = m;
        keys = (Key[]) new Object[M];
        values = (Value[]) new Object[M];
    }

    private int hash(Key key) {
        return (key.hashCode() & 0x7fffffff) % M;
    }

    public void put(Key key, Value value) {
        int i;
        for (i = hash(key); keys[i] != null; i = (i + 1) % M) {
            if (key.equals(keys[i])) {
                values[i] = value;
                return;
            }
        }
        keys[i] = key;
        values[i] = value;
        N++;
    }

    public Value get(Key key) {
        for (int i = hash(key); keys[i] != null; i = (i + 1) % M) {
            if (key.equals(keys[i])) {
                return values[i];
            }
        }
        return null;
    }
}

5.1、删除操作

 插入的操作需要线性探测,比如数组a={0,0,B,D,R,0,0},这时候我们插入的key=C转换后的索引是2的话,我们不能将B替换而是在它的后面查找,如果后面有C我们就替换,如果没有,我们会将C插入到R的后面(第一个空位的位置)。

上面我们插入C的数组变为a={0,0,B,D,R,C,0},如果我们删除了D之后,我们的数组会变为a={0,0,B,0,R,C,0}这时候如果我们需要查找C的话,因为C通过散列函数转换的索引还是2,如果我们从2的位置找C的话,B后面变成了null,所以我们会找不到C,这时候怎么办呢?需要将删除D之后,D后面的所有元素需要重新插入数组。

  public void delete(Key key) {
        if (!contains(key))
            return;

        int i = hash(key);
        while (!keys[i].equals(key)) {
            i = (i + 1) % M;
        }
        keys[i] = null;
        values[i] = null;
        N--;
        i = (i + 1) % M;
        while (keys[i] != null) {

            Key keyToRedo = keys[i];
            Value valToRedo = values[i];
            keys[i] = null;
            values[i] = null;
            N--;
            put(keyToRedo, valToRedo);
            i = (i + 1) % M;
        }
    }

    public boolean contains(Key key) {
        for (int i = hash(key); keys[i] != null; i = (i + 1) % M) {
            if (key.equals(keys[i])) {
                return true;
            }
        }
        return false;
    }

5.2、键蔟

 线性探测的平均成本取决于元素在插入数组后聚集成的一组连续的条目,也叫做键簇。例如, 数组a={0,0,B,D,R,0,0}就有一个长度为3的键簇(B D R)。这意味着插入 C 需要探测 4 次,因为 C 的散列值为该键簇 的第一个位置。显然,短小的键簇才能保证较高的效率。 随着插入的键越来越多,这个要求很难满足,较长的键簇 也会越来越多。

线性探测法的性能:当散列表快满的时候查找所需的探测次数是巨大的(较长的键蔟越来越多,探测的次数也越来越大),但当使用率 N/M=α 小于 1/2 时探测的预计次数只在 1.5 到 2.5 之间。

5.3、调整数组大小

   private void resize(int size){

        LinearProbingHashST st=new LinearProbingHashST(size);
        for (int i=0;i<M;i++){

            if (keys[i]!=null){
                st.put(keys[i],values[i]);
            }
        }
        keys= (Key[]) st.keys;
        values= (Value[]) st.values;
        M=st.M;
    }

6、内存使用

除了存储键和值所需的空间之外,我们实现的 SeparateChainingHashST 保存 了 M 个 SequentialSearchST 对象和它们的引用。每个 SequentialSearchST 对象需要 16 字节, 它的每个引用需要 8 字节。另外还有 N 个 node 对象,每个都需要 24 字节以及 3 个引用(key、 value 和 next),比二叉查找树的每个结点还多需要一个引用。

在使用动态调整数组大小来保证 表的使用率在 1/8 到 1/2 之间的情况下,线性探测使用 4N 到 16N 个引用。可以看出,根据内存用 量来选择散列表的实现并不容易。对于原始数据类型,这些计算又有所不同。

7、拉链法与线性探测法比较

拉链法和线性探测法的详细比较取决于实现的细节和用例对空间和时间的要求。即使基于性能 考虑,选择拉链法而非线性探测法也不一定是合理的。在实践中,两种方法的 性能差别主要是因为拉链法为每个键值对都分配了一小块内存而线性探测则为整张表使用了两个很 大的数组。对于非常大的散列表,这些做法对内存管理系统的要求也很不相同。在现代系统中,在 性能优先的情 下,最好由专家去把握这种平衡。

8、各种符号表性能比较

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值