如果把我们的字典看成一个大线性表的话, 也没有任何问题, 不过随着字典的扩大, 我们会发现, 这和现实中为了寻找一个词语的解释, 一页一页翻字典没有任何区别.

也就是说, 我们的字典看上去叫字典, 也实现了字典的接口, 但是在内部, 没有实现一本字典应该有的索引功能.

因此, 我们会想, 如果有一个索引函数, 我把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倍左右. 这确实是一个巨大的进步.