Java数据结构与算法解析——散列表

Posted 4K_WarCraft

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java数据结构与算法解析——散列表相关的知识,希望对你有一定的参考价值。

散列表概述

散列表就是一种以 键-值(key-indexed) 存储数据的结构,我们只要输入待查找的值即key,即可查找到其对应的值。

散列表的思路很简单,如果所有的键都是整数,那么就可以使用一个简单的无序数组来实现:将键作为索引,值即为其对应的值,这样就可以快速访问任意键的值。这是对于简单的键的情况,我们将其扩展到可以处理更加复杂的类型的键。

散列的查找算法有两个步骤: 
1.使用散列函数将被查找的键转换为数组的索引。在理想的情况下,不同的键会被转换为不同的索引值,但是在有些情况下我们需要处理多个键被哈希到同一个索引值的情况。所以散列查找的第二个步骤就是处理碰撞冲突。 
2.处理碰撞冲突。有很多处理散列碰撞冲突的方法,主要分为拉链法和线性探测法。

散列表是一个在时间和空间上做出权衡的经典例子。如果没有内存限制,那么可以直接将键作为数组的索引。那么所有的查找时间复杂度为O(1);如果没有时间限制,那么我们可以使用无序数组并进行顺序查找,这样只需要很少的内存。哈希表使用了适度的时间和空间来在这两个极端之间找到了平衡。只需要调整哈希函数算法即可在时间和空间上做出取舍。

散列函数和键的类型有关。对于每种类型的键我们都需要一个与之对应的散列函数。

散列函数

1. 正整数

获取正整数散列值最常用的方法是使用除留余数法。即对于大小为素数M的数组,对于任意正整数k,计算k除以M的余数。M一般取素数。

2. 字符串

将字符串作为键的时候,我们也可以将他作为一个大的整数,采用保留除余法。我们可以将组成字符串的每一个字符取值然后进行散列

char[] s = str.ToCharArray();
    int hash = 0;
    for (int i = 0; i < s.Length; i++)
    
        hash = s[i] + (31 * hash); 
    
    return hash;
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

上面的散列值是Horner计算字符串散列值的方法,公式为:

   h = s[0] · 31L–1 + … + s[L – 3] · 312 + s[L – 2] · 311 + s[L – 1] · 310
 
  • 1

举个例子,比如要获取”call”的哈希值,字符串c对应的unicode为99,a对应的unicode为97,L对应的unicode为108,所以字符串”call”的散列值为 3045982 = 99·313 + 97·312 + 108·311 + 108·310 = 108 + 31· (108 + 31 · (97 + 31 · (99)))

如果对每个字符去散列值可能会比较耗时,所以可以通过间隔取N个字符来获取散列值来节省时间,比如,可以 获取每8-9个字符来获取散列值:

char[] s = str.ToCharArray();
    int hash = 0;
    int skip = Math.Max(1, s.Length / 8);
    for (int i = 0; i < s.Length; i+=skip)
    
        hash = s[i] + (31 * hash);
    
    return hash;
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

3.Double类型

@Override
public int hashCode()  
  return Double.hashCode(value);

public static int hashCode(double value)  
  long bits = doubleToLongBits(value); 
  return (int)(bits ^ (bits >>> 32));



 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

Double类的hashCode方法首先会将它的值转为long类型,然后返回低32位和高32位的异或的结果作为hashCode。

4.非数值类型对象

前面我们介绍的数据类型都可以看做一种数值型(String可以看做一个整型数组),那么对于非数值类型对象的hashCode要怎么计算呢,这里我们以Date类为例简单的介绍一下。Date类的hashCode方法如下:

public int hashCode()  
  long ht = this.getTime(); 
  return (int) ht ^ (int) (ht >> 32);

 
  • 1
  • 2
  • 3
  • 4

它的hashCode方法的实现非常简单,只是返回了Date对象所封装的时间的低32位和高32位的异或结果。从Date类的hashCode的实现我们可以了解到,对于非数值类型的hashCode的计算,我们需要选取一些能区分各个类实例的实例域来作为计算的因子。比如对于Date类来说,通常具有相同的时间的Date对象我们认为它们相等,因此也就具有相同的hashCode。这里我们需要说明一下,对于等价的两个对象(也就是调用equals方法返回true),它们的hashCode必须相同,而反之则不然。

使用拉链法处理碰撞

散列算法的第二步就是碰撞处理,也就是处理两个或多个键的散列值相同的情况。

通过散列函数,我们可以将键转换为数组的索引(0-M-1),但是对于两个或者多个键具有相同索引值的情况,我们需要有一种方法来处理这种冲突。

一种比较直接的办法就是,将大小为M 的数组的每一个元素指向一个条链表,链表中的每一个节点都存储散列值为该索引的键值对,这就是拉链法。 
 
该方法的基本思想就是选择足够大的M,使得所有的链表都尽可能的短小,以保证查找的效率。对采用拉链法的哈希实现的查找分为两步,首先是根据散列值找到等一应的链表,然后沿着链表顺序找到相应的键。

拉链法的实现

public class SeperateChainingHashSet<K, V> 

    private int num; //当前散列表中的键值对总数
    private int capacity; //散列表的大小
    private SeqSearchST<K, V>[] st; //链表对象数组

    public SeperateChainingHashSet(int initialCapacity) 
        capacity = initialCapacity;
        st = (SeqSearchST<K, V>[]) new Object[capacity];
        for (int i = 0; i < capacity; i++) 
            st[i] = new SeqSearchST<>();
        
    

    private int hash(K key) 
        return (key.hashCode() & 0x7fffffff) % capacity;
    

    public V get(K key) 
        return st[hash(key)].get(key);
    

    public void put(K key, V value) 
        st[hash(key)].put(key, value);
    


 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

在上面的实现中,我们固定了散列表的容量,当我们明确知道我们要插入的键值对数目最多只能到达桶数的常数倍时,固定容量是完全可行的。但是若键值对数目会增长到远远大于桶数,我们就需要动态调整容量的能力。实际上,散列表中的键值对数与容量的比值叫做负载因子(load factor)。通常负载因子越小,我们进行查找所需时间就越短,而空间的使用就越大;若负载因子较大,则查找时间会变长,但是空间使用会减小。比如,Java标准库中的HashMap就是基于拉链法实现的散列表,它的默认负载因子为0.75。HashMap实现动态调整容量的方式是基于公式loadFactor = maxSize / capacity,其中maxSize为支持存储的最大键值对数,而loadFactor和capacity(容量)都会在初始化时由用户指定或是由系统赋予默认值。当HashMap中的键值对的数目达到了maxSize时,就会增大散列表中的容量。

以上代码中还用到了SeqSearchST,实际上这就是一个基于链表的符号表实现

public class SeqSearchST<K, V> 
        private Node first;

        private class Node 
            K key;
            V val;
            Node next;
            public Node(K key, V val, Node next) 
                this.key = key;
                this.val = val;
                this.next = next;
            
        

        public V get(K key) 
            for (Node node = first; node != null; node = node.next) 
                if (key.equals(node.key)) 
                    return node.val;
                
            
            return null;
        

        public void put(K key, V val) 
            //先查找表中是否已存在相应key 
            Node node;
            for (node = first; node != null; node = node.next) 
                if (key.equals(node.key)) 
                    node.val = val;
                    return;
                
            
            //表中不存在相应key 
            first = new Node(key, val, first);
        
    
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36

使用线性探测法处理碰撞

基本原理

线性探测法是另一种散列表的实现策略的具体方法,这种策略叫做开放定址法。开放定址法的主要思想是:用大小为M的数组保存N个键值对,其中M > N,数组中的空位用于解决碰撞问题。

线性探测法的主要思想是:当发生碰撞时(一个键被散列到一个已经有键值对的数组位置),我们会检查数组的下一个位置,这个过程被称作线性探测。线性探测可能会产生三种结果:

1.命中:该位置的键与要查找的键相同; 
2.未命中:该位置为空; 
3.该位置的键和被查找的键不同。

当我们查找某个键时,首先通过散列函数得到一个数组索引后,之后我们就开始检查相应位置的键是否与给定键相同,若不同则继续查找(若到数组末尾也没找到就折回数组开头),直到找到该键或遇到一个空位置。由线性探测的过程我们可以知道,若数组已满的时候我们再向其中插入新键,会陷入无限循环之中。

代码实现

我们使用数组keys保存散列表中的键,数组values保存散列表中的值,两个数组同一位置上的元素共同确定一个散列表中的键值对。

public class LinearProbingHashMap<K, V> 
    private int num; //散列表中的键值对数目
    private int capacity;
    private K[] keys;
    private V[] values;

    public LinearProbingHashMap(int capacity) 
        keys = (K[]) new Object[capacity];
        values = (V[]) new Object[capacity];
        this.capacity = capacity;
    

    private int hash(K key) 
        return (key.hashCode() & 0x7fffffff) % capacity;
    

    public V get(K key) 
        int index = hash(key);
        while (keys[index] != null && !key.equals(keys[index])) 
            index = (index + 1) % capacity;
        
        return values[index]; //若给定key在散列表中存在会返回相应value,否则这里返回的是null
    

    public void put(K key, V value) 
        if (num >= capacity / 2) 
            resize(2 * capacity);
        

        int index = hash(key);
        while (keys[index] != null && !key.equals(keys[index])) 
            index = (index + 1) % capacity;
        
        if (keys[index] == null) 
            keys[index] = key;
            values[index] = value;
            return;
        
        values[index] = value;
        num++;
    



 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44

删除操作

public void delete(K key) 
        if (!contains(key)) 
            return;
        
        int index = hash(key);
        while (!key.equals(keys[index])) 
            index = (index + 1) % capacity;
        
        keys[index] = null;
        values[index] = null;
        index = (index + 1) % capacity;
        while (keys[index] != null) 
            K keyToRedo = keys[index];
            V valueToRedo = values[index];
            keys[index] = null;
            values[index] = null;
            num--;
            put(keyToRedo, valueToRedo);
            index = (index + 1) % capacity;
        
        num--;
        if (num > 0 && num == capacity / 8) 
            resize(capacity / 8);
        

    
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

和拉链法一样,开放地址类的散列表的性能也依赖于α=num/capacity的比值 ,我们将α成为散列表的使用率。α是表中已被占用的空间的比例,它是不可能大于1的。LinearProbingHashMap中我们不允许α达到1(散列表被占满),因此未命中的查找会导致无限循环。为了保证性能,我们会动态调整数组的大小来保证使用率在1/8到1/2之间。

动态调整数组大小

在实际应用中,当负载因子(键值对数与数组大小的比值)接近1时,查找操作的时间复杂度会接近O(n),当负载因子(键值对数与数组大小的比值)接近1时,而数组的容量又是固定的时候,while循环会变为一个无限循环。所以有必要实现动态增长数组来保持查找操作的常数时间复杂度。当键值对总数很小时,若空间比较紧张,可以动态缩小数组,这取决于实际情况。

也就是put方法中的这段代码

 if (num >= capacity / 2) 
            resize(2 * capacity);
        
 
  • 1
  • 2
  • 3

resize方法:

private void resize(int i) 
        LinearProbingHashMap<K, V> hashmap = new LinearProbingHashMap<>(newCapacity);
        for (int i = 0; i < capacity; i++) 
            if (keys[i] != null) 
                hashmap.put(keys[i], values[i]);
            
        
        keys = hashmap.keys;
        values = hashmap.values;
        capacity = hashmap.capacity;