Java 数据结构 散列表 - 拉链法

Java 数据结构 散列表 - 拉链法

如果把我们的字典看成一个大线性表的话, 也没有任何问题, 不过随着字典的扩大, 我们会发现, 这和现实中为了寻找一个词语的解释, 一页一页翻字典没有任何区别. 也就是说, 我们的字典看上去叫字典, 也实现了字典的接口, 但是在内部, 没有实现一本字典应该有的索引功能. 因此, 我们会想, 如果有一个

如果把我们的字典看成一个大线性表的话, 也没有任何问题, 不过随着字典的扩大, 我们会发现, 这和现实中为了寻找一个词语的解释, 一页一页翻字典没有任何区别. 也就是说, 我们的字典看上去叫字典, 也实现了字典的接口, 但是在内部, 没有实现一本字典应该有的索引功能. 因此, 我们会想, 如果有一个索引函数, 我把Key放进去, 运算出来的结果就是一个索引, 然后直接操作索引位置的对象就可以了, 这才是完备的字典. 这个想法确实是对的, 有这么一种函数, 就是散列函数. 当然, 也许工作的不想想象的那样完美, 但可以大大的提高查找效率.
  1. 散列表实现的字典
  2. 散列函数与散列码的计算
  3. 下标压缩
  4. 解决冲突
  5. 装填因子
  6. 拉链法实现散列表
  7. 添加长度变更功能

散列表实现的字典

对于我们的字典, 散列函数的核心思想就是: 根据传入的对象, 计算得到对象在数组中的元素下标. 所以这个函数的本质, 就是将一组事物, 映射到一排整数上去. 如果每个事物都可以映射到不同的整数上去, 这个就叫做完美散列函数. 将一个事物通过散列函数计算得到的整数称为散列码. 完美散列函数的一大问题是, 如果这组事物很多, 则需要的散列表空间也非常大, 至少在编号方面非常大. 所以在很多时候, 可能还需要把散列码压缩到数据结构可实际使用的空间中去, 比如一个对象的散列码计算得到-10, 就不能直接用这个散列码来操作, 还需要为-10这个散列码寻找在数组中的对应位置. 此外, 在计算散列码的时候(或者说直到计算出最终下标)的过程中, 有些不同的对象映射到同一个下标, 这个叫做冲突. 这里先不讨论如何解决冲突, 而是肯定有一个冲突解决方案来做这个工作. 这样我们就初步整理出来了使用散列表的几个核心要素:
  1. 散列函数和散列码
  2. 散列码换算成下标
  3. 冲突解决方案
Java中的基类Object有一个方法叫做hashCode(), 就是散列函数, 计算得到一个int类型的整数, 就是散列码. 不过, 如果不覆盖这个方法, 默认的实现是返回这个对象所在的内存地址当做散列码. 默认的内存地址不实用, 因为当指针指向另外一个对象的时候, 内存地址相同, 但对象发生了变化, 因此很难通过散列码来判断对象的相等性. Java对此有一些严格的规定, 即判断相等性equals()和hashCode()的方法, 在编写自己的类时候尤其要注意:
  1. 如果一个类重写了equals(),也必须重写hashCode()
  2. 如果两个对象equals()的结果是true, 则hashCode()的结果也必须相等
  3. 一个对象不管调用多少次hashCode(), 只要对象未改变, 结果都一样

散列函数

由上边分析可见, 散列函数非常重要, 好的散列函数可以大大减少之后压缩下标, 解决冲突的难度. 来看一下Java中是如何计算散列函数的.

字符串散列函数

字符串散列函数的Horner方法是, 反复乘再加, 不好说但是一看代码就明白. 根据这个方法, 可以选择一个常数, 来编写一个散列函数:
public static int hashString(String s) {

    //所选择的常数
    int A = 31;

    //初始hash码
    int hash = 0;

    for (int i = 0; i < s.length(); i++) {

        //每次用常数乘以hash码,再加上当前字符的unicode值
        hash = hash * A + s.charAt(i);
    }
    return hash;
}
这里我们的例子选择了31, 这是因为Java的String类的hashCode()方法选择的就是31. 所以我们的散列函数计算出来的结果和Java是一样的. 不信可以试试.

基本类型的散列函数

对于长度不超过int的整数类型 byte short char int, 直接可以将其转换为int就得到散列码, 数字的唯一性保证了散列码的唯一性. float类型很有意思, 由于其内部就是一个32位的二进制数表示, 因此可以直接将其转换成 32位 相同规格的int即可, 注意这和转换类型可不同. 需要使用Float.floatToRawIntBits(a)来将其转换成原样的二进制码对应的int, 这也保证了唯一性. 对于long和Double,我们马上就可以判定, 如果将散列码扩展到64位, 则可以用同样的方式保证唯一性. 不幸的是散列码是int类型,因此需要压缩一下, 采用的方法是将64位的前32位和后32位进行异或运算, 得到32位长度的散列码. 写成方法就是:
public static int hash64BitDate(long s) {

    return (int) ((s >> 32) ^ s);
}
对于Double类型, 则要稍微做点变更,不能直接移位, 而要先转换成原始的二进制对应的long:
public static int hash64BitDate(double s) {

    return hash64BitDate(Double.doubleToLongBits(s));

}
上边的这些函数, 手工计算结果和调用包装类的hashCode方法,结果是一样的. 对于普通对象的散列码, 则需要在编写对象的时候想一个方法来进行编写, 一般可以使用对象中各个数据域的散列码来进行计算. 还一个常见的操作是, 因为计算散列码需要时间, 因此可以设置一个布尔变量来表示散列码是否已经被计算出来, 如果对象的数据发生变化, 则重新计算散列码, 否则直接返回已经计算好的散列码即可.

下标压缩

散列码都是int类型的值, 给了我们一个启示, 就是可以将其压缩到一个数字序列中. 只要选定一个素数, 然后取散列码除以该素数的余数, 就可以将散列码映射到 0 - (素数-1) 范围内. 很容易想到, 如果选择其他数字或者偶数, 则出现重复下标的概率大大提高, 所以n要是奇数, 最好的选择就是某个素数了. 知道了散列码如何计算, 以及如何将散列码压缩至下标, 剩下就需要解决一个问题, 就是两个不同的散列码压缩到同一个下标的时候, 该如何处理. 所以就来研究如何解决冲突.

解决冲突

这里我们先跳过线性探查, 而是专注于拉链法 - 即需要改变字典的内部数据结构. 所谓改变结构, 也就是让线性表的每一个位置, 都可以来存入多个元素, 这只需要把线性表的每一个元素, 也改成一个存放Entry的数据结构. 这个数据结构可以是之前用过的数组, 线性表, 向量等等都可以, 只要支持查找和替换操作, 一般来说, 使用链表组成的线性表会比较方便, 因为不会占用额外空间. 所以实际上, 通过散列表, 有效的减少了操作一条长链的可能性, 将每个查找, 都对应到一条相比整个字典短的多的链条上, 这样查找效率就会大大提高. 这样, 散列表的内部结构, 就变成了一个线性表数组, 这是很有意思的. 看起来已经可以写一个散列表了.

装填因子

这里我们依然关心拉链法. 装填因子 load factor, 就是散列表中已经存储的总项数 / 散列表中的位置数. 对于拉链法来说, 散列表的位置数并不是指总的位置数, 因为链表是可伸缩的, 而是指将散列码要压缩到的范围. 所以对于拉链法来说, 可能存了100个数字, 但是依然每次只有0-16这17个下标,所以没有上限. 然而, 装填因子却影响着查找效率. 拉链法下, 很显然, 设装填因子=A, 则实际上A是线性表数组每一项里边的线性表中的平均元素个数. 根据我们的算法, 对象会被一个常数级别的运算计算出散列码, 然后被常数级别的运算计算出下标. 之后影响性能的就是在A个元素中的查找能力. 先看第一个情况:

查找不成功

查找不成功意味着最后没有找到任何一个键和给出的键对应. 很显然, 在两个常数操作之后, 面对的平均情形就是一个长度为A的链表, 只有检查完全部节点之后, 才能得出查找不成功的结论, 所以查找不成功的次数是A.

查找成功

查找成功到确定下标之前, 也是常数操作, 之后面临和查找不成功一样的境地, 即一个长度为A的链表, 很显然, 要么第一个就查到, 即花费1, 要么最后一个才查到, 因此平均时间是1 + A/2. 这样, 在给定的线性表大小和放入元素的个数之后(也就是装填因子), 就可以计算出来查找所需的时间的粗略关系. 经过计算, 从A=0.1开始, 随着不断增大装填因子, (也就是线性表装的东西越来越多), 不成功查找所需的时间的增加量会大于成功查找的增加量. 一般将装填因子维持在1以内比较好, 如果超过, 则应该将线性表数组扩展到下一个素数. 有了这些理论, 就可以来写出一个散列表了. 当然, 散列函数可能还是需要我们自行设计. 但是这里可以先以最常见的字符串散列表为例.

拉链法实现散列表

根据上边的需求, 自己先来编写一个简单的拉链法字典, 先搭建一个基础设施:
public class ZipDictionary<K, V> implements Dictionary<K, V> {

    private MyLinkedList<Entry<K, V>>[] dictionary;

    private static int DEFAULT_CAPACITY = 17;

    private int numberOfEntries;

    @SuppressWarnings("unchecked")
    public ZipDictionary() {
        dictionary = new MyLinkedList[DEFAULT_CAPACITY];
    }

    private class Entry<K, V> {
        K key;
        V value;

        Entry(K key, V value) {
            this.key = key;
            this.value = value;
        }

        @Override
        public String toString() {
            return "Entry{" +
                    "key=" + key +
                    ", value=" + value +
                    '}';
        }

        @Override
        @SuppressWarnings("unchecked")
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            Entry<?, ?> entry = (Entry<?, ?>) o;
            return key.equals(entry.key);
        }

        @Override
        public int hashCode() {
            return key.hashCode();
        }
    }
}
基础设施包括了一个数组, 每一个元素都是一个链表实现的线性表. 还有老朋友Entry. 下边一个一个来编写各个方法. 首先是核心的add()方法.
@Override
public void add(K key, V value) {
    if (key == null || value == null) {
        throw new RuntimeException("不允许添加null.");
    }

    Entry<K, V> newEntry = new Entry<>(key, value);

    //关键步骤, 根据键的散列码压缩下标
    int index = Math.abs(newEntry.hashCode() % dictionary.length);
    //如果为空就先创建线性表, 然后添加
    if (dictionary[index] == null) {
        dictionary[index] = new MyLinkedList<>();
        dictionary[index].add(newEntry);
    } else {
    //如果不为空, 需要先探查有没有这个东西

        int result = dictionary[index].getPosition(newEntry);
        //如果没找到, 就直接添加, 可以考虑添加在头部, 这样下一次查找会比较快
        if (result == -1) {
            dictionary[index].add(0, newEntry);
        } else {
        //如果找到了, 就替换指定位置的元素
            dictionary[index].replace(result, newEntry);
        }
    }

}
接下来是remove()方法. 逻辑依然是先计算散列码再查找:
@Override
@SuppressWarnings("unchecked")
public V remove(K key) {
    int index = Math.abs(key.hashCode() % dictionary.length);
    System.out.println("键的散列码是: " + index);

    Entry<K, V> target = new Entry<>(key, (V) new Object());

    if (dictionary[index] == null) {
        return null;
    } else {
        int result = dictionary[index].getPosition(target);
        System.out.println("具体链表的索引是: " + result);
        if (result == -1) {
            return null;
        } else {
            V value = dictionary[index].getEntry(result).value;
            dictionary[index].remove(result);
            return value;
        }
    }
}
然后是通过键获取值, 如出一辙:
@Override
@SuppressWarnings("unchecked")
public V getValue(K key) {
    int index = Math.abs(key.hashCode() % dictionary.length);
    Entry<K, V> target = new Entry<>(key, (V) new Object());

    if (dictionary[index] == null) {
        return null;
    } else {
        int result = dictionary[index].getPosition(target);
        if (result == -1) {
            return null;
        } else {
            return dictionary[index].getEntry(result).value;
        }
    }

}
之后是contains(), 还是一样的操作:
@Override
@SuppressWarnings("unchecked")
public boolean contains(K key) {
    int index = Math.abs(key.hashCode() % dictionary.length);
    Entry<K, V> target = new Entry<>(key, (V) new Object());

    if (dictionary[index] == null) {
        return false;
    } else {
        return dictionary[index].getPosition(target) != -1;
    }

}
最后是几个辅助函数, 编写都依赖于每个链表:
@Override
public boolean isEmpty() {
    int sum = 0;

    for (MyLinkedList<Entry<K, V>> entries : dictionary) {
        if (entries != null) {
            sum = sum + entries.getLength();
        }
    }
    return sum == 0;
}
@Override
public int getSize() {
    int sum = 0;

    for (MyLinkedList<Entry<K, V>> entries : dictionary) {
        if (entries != null) {
            sum = sum + entries.getLength();
        }
    }
    return sum;
}
@Override
public void clear() {
    Arrays.fill(dictionary, null);
}
现在只剩下迭代器了. 迭代器我能想到的一个方法就是遍历一遍然后组装一个数组, 让迭代器去使用. 单独控制一个数量i也可以, 但是每次要一个一个往前走, 似乎写起来非常麻烦. 这里我就组装了一个数组然后返回一个迭代器对象, 先看键迭代器:
@Override
public Iterator<K> getKeyIterator() {
    return new KeyIterator();
}

private class KeyIterator implements Iterator<K>{

    private K[] array;

    private int index;

    private int number;

    @SuppressWarnings("unchecked")
    KeyIterator() {
        this.index = 0;
        this.number = getSize();
        array = (K[]) new Object[number];

        int i = 0;

        //这里实际上把当前所有Key都装进了新创建的数组, 然后实际上是在这个数组中迭代
        for (MyLinkedList<Entry<K, V>> list : dictionary) {
            if (list != null) {
                for (Entry<K, V> entry : list) {
                    array[i] = entry.key;
                    i++;
                }
            }
        }
    }

    @Override
    public boolean hasNext() {
        return index != number;
    }

    @Override
    public K next() {
        K result = array[index];
        index++;
        return result;
    }
}
会编写键的迭代器了, 值的也一样:
@Override
public Iterator<V> getValueIterator() {
    return new ValueIterator();
}

private class ValueIterator implements Iterator<V>{

    private V[] array;

    private int index;

    private int number;

    @SuppressWarnings("unchecked")
    ValueIterator() {
        this.index = 0;
        this.number = getSize();
        array = (V[]) new Object[number];

        int i = 0;

        for (MyLinkedList<Entry<K, V>> list : dictionary) {
            if (list != null) {
                for (Entry<K, V> entry : list) {
                    array[i] = entry.value;
                    i++;
                }
            }
        }
    }

    @Override
    public boolean hasNext() {
        return index != number;
    }

    @Override
    public V next() {
        V result = array[index];
        index++;
        return result;
    }
}
这样一个固定长度的字典基本上就写好了. 不过这里没有编写长度发生改变的方法.

添加长度变更功能

长度发生改变的话, 就需要在每次add方法之前, 判断装填因子是不是超过一定的数量, 也就是用getSize()/dictionary.length, 如果大于一个数字, 就要扩充到下一个素数的位置, 还会需要一些辅助的素数行列来控制大小和上限, 这里对原来的类进行一些修改, 在add方法添加元素之前, 增加一个扩大数组的方法. 这里需要修改一下整个类的一些设施, 将原来的那个静态变量DEFAULT_CAPACITY去掉, 记录当前数组的长度(其实这个变量也可以省略, 不过为了简明, 就写出来),然后就可以来编写辅助域和方法, 用于在add()添加元素之前扩大数组长度:
public class ZipDictionary<K, V> implements Dictionary<K, V> {

    private int capacity = 17;

    private static int[] primeNumbers = new int[]{37, 79, 151, 307, 617, 1237, 2477, 4957, 10007};

    @Override
    public void add(K key, V value) {
        if (key == null || value == null) {
            throw new RuntimeException("不允许添加null.");
        }

        checkSize();

        Entry<K, V> newEntry = new Entry<>(key, value);

        int index = Math.abs(newEntry.hashCode() % dictionary.length);

        if (dictionary[index] == null) {
            dictionary[index] = new MyLinkedList<>();
            dictionary[index].add(newEntry);
        } else {

            int result = dictionary[index].getPosition(newEntry);
            if (result == -1) {
                dictionary[index].add(0, newEntry);
            } else {
                dictionary[index].replace(result, newEntry);
            }
        }

    }

    @SuppressWarnings("unchecked")
    private void checkSize() {
        double loadFactor = (double) getSize() / dictionary.length;
        System.out.println("当前的装填因子是: " + loadFactor);

        if (loadFactor > 0.7) {
            for (int primeNumber : primeNumbers) {
                if (primeNumber > capacity) {
                    capacity = primeNumber;
                    System.out.println("将容量增大到: " + capacity);
                    //用一个临时引用保存原来的内部数据结构
                    MyLinkedList<Entry<K, V>>[] temp = dictionary;

                    //然后创建一个长度扩大的数组作为新字典的内部数据结构
                    dictionary = new MyLinkedList[capacity];

                    //由于是散列, 不能直接复制, 所以只好一个一个把原来的散列表的内容装载到新的散列表中.
                    loadDataToNewDictionary(temp);

                    break;
                }
            }
        }
    }


    private void loadDataToNewDictionary(MyLinkedList<Entry<K, V>>[] array) {
        for (MyLinkedList<Entry<K, V>> list : array) {
            if (list != null) {
                for (Entry<K, V> entry : list) {
                    add(entry.key, entry.value);
                }
            }
        }
    }

}
checkSize()方法检测当前的装填因子是不是已经大于0.7, 只要大于, 就在素数表中搜索下一个大于capactiy的素数, 并将capacity设置成这个素数. 然后创建一个引用指向原来的内部链表数组, 将dictionary的引用替换成新的capacity大小的空白数组, 然后使用一个辅助方法, 将原来链表数组中的所有元素, 重新添加到新的散列表中. 这里可不能像之前线性表一样重新复制, 因为散列表压缩之后的下标会变化. 这个要特别注意. 上限就是10007, 即数组最大也就扩大到10007长度, 之后便不再扩大散列表了. 经过了一下午, 终于完整的写出了拉链法还能够扩大的散列表了, 在扩大散列表的时候, 是一个O(n)的操作. 在正常取散列表的时候, 假设装填因子是0.7, 说明平均每个位置只有0.7个元素, 由于散列过程可以看做是常数, 所以散列表的性能相比线性表,可就非常快了. 所以最后来写一个小实验, 比一下究竟两者性能差多少.
public static void main(String[] args) {

    Random random = new Random();

    ZipDictionary<String, String> dictionary1 = new ZipDictionary<>();

    for (int i = 0; i < 10000; i++) {
        dictionary1.add(String.valueOf(i), String.valueOf(i * 3));
    }

    LinearDictionary<String, String> dictionary2 = new LinearDictionary<>();

    for (int i = 0; i < 10000; i++) {
        dictionary2.add(String.valueOf(i), String.valueOf(i * 3));
    }

    String[] indexNumbers = new String[1024*1024];

    for (int i = 0; i < indexNumbers.length; i++) {
        indexNumbers[i] = String.valueOf(random.nextInt(10000));
    }

    long start = System.currentTimeMillis();
    for (int i = 0; i < indexNumbers.length; i++) {
        dictionary1.getValue(indexNumbers[i]);
    }
    long end = System.currentTimeMillis();

    System.out.println("拉链法字典查找时间是: " + (end - start));

    start = System.currentTimeMillis();
    for (int i = 0; i < indexNumbers.length; i++) {
        dictionary2.getValue(indexNumbers[i]);
    }
    end = System.currentTimeMillis();
    System.out.println("线性表字典查找时间是: " + (end - start));

}
测试首先给长度10000的拉链法字典和线性表字典各装入10000个键值对, 键就是0-9999的字符串, 值是键乘以3的字符串. 然后准备了长度为1M的键数组, 放了所有随机生成的键. 之后让两个程序挨个搜索键数组, 比较完成这1M次数的时间. 运行了几次如下:
拉链法字典查找时间是: 82
线性表字典查找时间是: 28842

拉链法字典查找时间是: 80
线性表字典查找时间是: 28567

拉链法字典查找时间是: 86
线性表字典查找时间是: 29866
可以看到, 性能差距在350倍左右. 这确实是一个巨大的进步.
LICENSED UNDER CC BY-NC-SA 4.0
Comment