哈希表(一)

哈希表是一种数据结构,它可以提供快速的插入和删除操作。无论哈希表有多少数据,插入、删除只需要接近常量的时间,即 O(1) 的时间级。明显比树还快,树的操作通常需要O(N)的时间级。

缺点:它是基于数组的,数组创建之后难以维护。某些哈希表被基本填满时,性能下降非常严重。而且也没有提供一种方法可以以任何一种顺序(例如从大到小)遍历表中数据项。

若需把单词当做key(数组下标)获取value(数据),可以把单词分解成字母组合,把字母转化为它们的数字代码(a-1,b-2,c-3……z-26,空格-27),每个数字乘以相应的27(因为字母有27种可能,包括空格)的幂,然后结果相加,就可以每个单词对应一个独一无二的数字。

例如 cats 转换数字:3*273 + 1*272 + 20*271 + 19*270 = 60337

这种方案会使得数组的长度太大,而且只有很少的一部分下标是有数据的。

哈希化

arrayIndex = hugeNumber % arraySize,这是一种哈希函数,它把一个大范围的数字哈希(转化)成一个小数字的范围。

使用取余操作符(%),把巨大的整数范围转换为两倍于要存储内容的数组下标范围。下面是哈希函数的例子:

arraySize = wordNumber * 2; 
arrayIndex = hugeNumber % arraySize;

期待的数组应该有这样的特点:平均起来,每两个数组单元,就有一个数值,有些单元没有数值,但有些单元可能有多个数值。


冲突

把巨大的数字空间压缩为较小的数字空间,必然要付出代价,即不能保证每个单词都映射到数组的空白单元。假设在数组中需要插入单词zoo,哈希化之后得到它的下标,发现该单元已经有了其它另一个不同的单词,这个情况叫做“冲突”。

解决方案1 - 开放地址法

前面已经提过指定的数组大小两倍于需要存储的数据量,因此还有一半单元是空白的。当发生冲突的时候,通过系统的方法找到数组的一个空位,并把单词放进去,而不再用哈希函数得到的数组下标,这种方法叫做“开放地址法”。

解决方案2 - 链地址法

创建一个存放单词链表的数组,数组内不直接存储单词,这样,但发生冲突的时候,新的数据项直接接到这个数组下标所指的链表当中。这种方法叫做“链地址法”。


开放地址法

寻找数组的其它位置有三种方法:线性探测、二次探测、再哈希法。

1)线性探测

线性查找空白单元,如果 21 是要插入数据的位置,它已经被占用了。那么就使用 22 ,然后是 23 ,以此类推,数组下标一直递增,直到找到空位为止。

插入(insert)

当数据项的数目占哈希表的一半,或最多三分之二时,哈希表的性能是最好的。可以看出已填充的单元分布不均匀,有时一串空白单元,有时有一串已填充的单元。

在哈希表中,一串连续的已填充单元叫做“填充序列”。增加越来越多的数据项时,填充序列变得越来越长,这叫做“聚集”。

删除(Delete)

在哈希表中,查找算法是以哈希化的关键字开始,沿着数组一个一个寻找,如果在寻找到关键字之前遇到一个空白单元,说明查找失败。

delete不是简单地把某个单元的数据项变为空白(null),因为在一个填充序列中间有个空白,查找算法就会中途放弃查找。因此需要一个有特殊关键字的数据项代替要被delete的数据项。标记数据项不存在。

public class DataItem {

    private int i;

    public DataItem(int i) {
        this.i = i;
    }

    public int getKey() {
        return i;
    }

    public void printf() {
        System.out.println("data -> " + i);
    }

}
public class HashTable {

    private DataItem[] itemArray;

    private int arraySize;

    private DataItem nonItem; // for deleted items

    public HashTable(int size) {
        this.arraySize = size;
        itemArray = new DataItem[arraySize];
        nonItem = new DataItem(-1);
    }

    public void display() {
        for (DataItem data : itemArray) {
            if (data != null) {
                data.printf();
            }
        }
    }

    public int hashFuc(int key) {
        return key % arraySize;
    }

    public void insert(DataItem item) {
        int key = item.getKey();
        int hashVal = hashFuc(key);
        DataItem tItem;
        while ((tItem = itemArray[hashVal]) != null && tItem.getKey() != -1) {
            if (tItem.getKey() == key) {
                itemArray[hashVal] = item;
                return;
            }
            hashVal++; // go to next cell
            hashVal %= arraySize; // wraparound if necessary
        }
        itemArray[hashVal] = item;
    }

    public DataItem delete(int key) {
        int hashVal = hashFuc(key);
        DataItem item;
        while ((item = itemArray[hashVal]) != null) { // until empty cell
            if (item.getKey() == key) {
                itemArray[hashVal] = nonItem;
                return item;
            }
            hashVal++;
            hashVal %= arraySize;
        }
        return null;
    }

    public DataItem find(int key) {
        int hashVal = hashFuc(key);
        DataItem item;
        while ((item = itemArray[hashVal]) != null) { // until empty cell
            if (item.getKey() == key) {
                return item;
            }
            hashVal++;
            hashVal %= arraySize;
        }
        return null;
    }

}
    public static void main(String[] args) {
        HashTable t = new HashTable(10);
        t.insert(new DataItem(39));
        t.insert(new DataItem(51));
        t.insert(new DataItem(23));
        t.insert(new DataItem(25));
        t.insert(new DataItem(23));
        t.insert(new DataItem(10));
        t.insert(new DataItem(9));
        t.delete(25);
        t.insert(new DataItem(79));
        t.insert(new DataItem(81));
        t.display();
    }

打印结果:
data -> 10
data -> 51
data -> 9
data -> 23
data -> 79
data -> 81
data -> 39


扩展数组

当哈希表太满,需要扩展数组。只能创建一个新的更大的数组,然后把旧的数组所有数据项插入到新的数组。由于哈希函数是根据数组的大小计算数据项的位置,所以不能简单把一个数据项插入新的数组,需要按顺序遍历旧的数组,然后调用 insert()向新的数组插入每个数据项。这叫做“重新哈希化”。

扩展后的数组容量通常是原来的两倍,实际上数组的容量应该是一个质数,所以新的数组要比两倍容量多一点。

好的HASH函数需要把原始数据均匀地分布到HASH数组里,比如大部分是偶数,这时候如果HASH数组容量是偶数,容易使原始数据HASH后不会均匀分布:

2 4 6 8 10 12这6个数,如果对 6 取余 得到 2 4 0 2 4 0 只会得到3种HASH值,冲突会很多。如果对 7 取余 得到 2 4 6 1 3 5 得到6种HASH值,没有冲突。

同样地,如果数据都是3的倍数,而HASH数组容量是3的倍数,HASH后也容易有冲突,用一个质数则会减少冲突的概率,更分散。

以下是求质数的代码:

    private int getPrime(int min) {
        for (int j = min;; j++) {
            if (isPrime(j)) {
                return j;
            }
        }
    }

    private boolean isPrime(int num) {
        for (int j = 2; j * j <= num; j++) {
            if (num % j == 0) {
                return false;
            }
        }
        return true;
    }


3)二次探测

在线性探测中会发生聚集,一旦聚集形成,它会越来越大,哈希化后的落在聚集范围内的数据项都要一步步移动,性能越差。

装填因子:已填入哈希表的数据项和表长的比率叫做装填因子。loadFactor = nItems / arraySize ;

二次探测是防止聚集的产生,思想是探测相隔较远的单元,而不是相邻的单元。

步骤是步数的平方:假设哈希表中原始下标是x,那么线性探测是:x+1,x+2,x+3……;而在二次探测中,探测过程是:x+12,x+22,x+32……。

二次探测消除了在线性探测产生的聚集问题,这种聚集问题叫做“原始聚集”。然而二次探测产生了另外一种更细的聚集问题。之所以会发生,是因为所有映射到同一个位置的关键字在寻找空位时,探测的单元都是一样的(步长总是固定的,都是:1、4、9、16、25、36……)。


4)再哈希法

为了消除原始聚集和二次聚集,可使用另一种方法:再哈希法。现在需要的一种方法产生一种依赖关键字的探测序列,而不是每个关键字都一样,那么,不同的关键字即使映射到相同的数组下标,也可以使用不同的探测序列。

方法是把关键字用不同的哈希函数再做一遍哈希化,用这个结果作为步长。对于指定的关键字,步长在整个探测是不变的,不过不同关键字使用不同步长。

经验说明,第二哈希函数必须具备以下条件:

  1. 与第一个哈希函数不同
  2. 不能输入0(否则没有步长,每次探测都原地踏步,死循环。)

stepSize = constant - (key % constant),其中 constant 是质数,且小于数组容量。例如:stepSize = 5 - key % 5 ;

public class HashTable2 {

    private DataItem[] itemArray;

    private int arraySize;

    private DataItem nonItem; // for deleted items

    public HashTable2(int size) {
        this.arraySize = size;
        itemArray = new DataItem[arraySize];
        nonItem = new DataItem(-1);
    }

    public void display() {
        for (DataItem data : itemArray) {
            if (data != null) {
                data.printf();
            }
        }
    }

    public int hashFuc1(int key) {
        return key % arraySize;
    }

    public int hashFuc2(int key) {
        /*
         * non-zero, less than array size, different from hashFuc1. array size
         * must be relatively prime to 5, 4, 3, 2
         */
        return 5 - key % 5;
    }

    public void insert(DataItem item) {
        int key = item.getKey();
        int hashVal = hashFuc1(key);
        int stepSize = hashFuc2(key);
        DataItem tItem;
        while ((tItem = itemArray[hashVal]) != null && tItem.getKey() != -1) {
            if (tItem.getKey() == key) {
                itemArray[hashVal] = item;
                return;
            }
            hashVal += stepSize; // add the step
            hashVal %= arraySize; // wraparound if necessary
        }
        itemArray[hashVal] = item;
    }

    public DataItem delete(int key) {
        int hashVal = hashFuc1(key);
        int stepSize = hashFuc2(key);
        DataItem item;
        while ((item = itemArray[hashVal]) != null) { // until empty cell
            if (item.getKey() == key) {
                itemArray[hashVal] = nonItem;
                return item;
            }
            hashVal += stepSize;
            hashVal %= arraySize;
        }
        return null;
    }

    public DataItem find(int key) {
        int hashVal = hashFuc1(key);
        int stepSize = hashFuc2(key);
        DataItem item;
        while ((item = itemArray[hashVal]) != null) { // until empty cell
            if (item.getKey() == key) {
                return item;
            }
            hashVal += stepSize;
            hashVal %= arraySize;
        }
        return null;
    }

}

表的容量必须是一个质数

再哈希法要求表的容量是一个质数。为什么会有这个限制,假设表的容量不是质数,表长是15(坐标 0 - 14),有一个特别关键字映射到0,步长为5,探测序列为0、5、10、0、5……,一直循环下去,算法只会尝试这三个单元,不可能找到其它空白单元,算法崩溃。

如果数组容量是13,即一个质数,那么探测序列会访问到所有单元。即0、5、10、2、7、12、4、9、1、6、11、3,一直下去,只要表中有一个空位,就可以探测到它。用质数作为数组容量使得任何数想整除它是不可能的,因此探测序列最终会检查到所有单元。

原文地址:https://www.cnblogs.com/xuekyo/p/2911203.html