十三、哈希表
哈希表是一种数据结构,它可以提供快速的插入操作和查找操作,不论哈希表中有多少数据,插入和删除只需要接近常量的时间,即O(1)的时间级。
哈希表的缺点:它是基于数组的,数组创建后难于扩展。某些哈希表被基本填满时,性能下降得非常严重,所以程序员必须要清楚表中将要存储多少数据(或者准备好定期地把数据转移到更大的哈希表中,这是个费时的过程。)
假设想在内存中存储50000个英文单词。起初可能考虑每个单词占据一个数组单元,那么数组大小是50000,同时可以使用数组下标存取单词。这样,存取确实很快。但是数组下标和单词有什么关系呢?例如给出一个单词cats,怎么能找到它的数组下标呢?
把单词转化为数组下标
把数字相加c=3,a=1,t=20,s=19,3+1+20+19=43,那么单词cats存储在数组下标为43的单元中。
第一个单词a的编码是0+0+0+0+0+0+0+0+0+1=1,字典最后一个可能的单词是zzzzzzzzzz,编码是26+26+26+26+26+26+26+26+26+26=260。因此,单词编码的范围是从1到260。不幸的是,字典中有50000个单词,所以没有足够的数组下标数来索引那么多的单词。每个数组数据项大概要存储192个单词。用一个单词占用一个数组单元的方案会发生问题。也许可以考虑每个数组数据项包含一个子数组或链表。不幸的是,这个办法严重降低了存取速度。存取数据项确实很快,但是要在192个单词中找到其中一个,速度就很慢。、
幂的连乘cats=3*273 + 1*272 + 20*271 + 19*270=60337。如果是十位字符串,数字值会非常大。如果为每个可能的单词分配一个数组单元,不管这个单词是不是真正的英语单词。从aaaaaaaaaa到zzzzzzzzzz,这些单元只有一小部分存放了存在的英语单词,而大多数单元是空的。在内存中的数组也根本不可能有这么多的单元。
第一种方案(数字相加求和)产生的数组下标太少。第二种方案(与27的幂相乘并求和)产生的数组下标又太多。
哈希化
现在需要一种压缩方法,把数位幂的连乘系统中得到的巨大的整数范围压缩到可接受的数组范围中。
假如只有50000个单词,需要容量为100000的数组(多一倍的空间效率更高)。有个简单的方法,使用取余操作符,把0到7000000000000的范围压缩为0到100000。
arrayIndex = hugeNumber % arraySize
这就是一种哈希函数。它把一个大范围的数字哈希(转化)成一个小范围的数字。这个小的范围对应着数组的下标。使用哈希函数向数组插入数据后,这个数组就称为哈希表。
冲突
把巨大的数字空间压缩成较小的数字空间,必然要付出代价,即不能保证,每个单词都映射到数组的空白单元。之前设置了数组的大小是需要存储的数据量的两倍。因此,可能一半的单元是空的。当冲突发生时,一个方法是通过系统的方法找到数组的一个空位,并把这个单词填入,而不再用哈希函数得到的数组下标。这个方法叫做开放地址法。例如,如果cats哈希化的结果是5421,但它的位置已经被parsnip占用,那么可能会考虑把cats放在5422的位置上。第二种方法是创建一个存放单词链表的数组,数组内不直接存储单词。这样,当发生冲突时,新的数据项直接接到这个数组下标所指的链表中。这种方法叫做链地址法。
开放地址法
在开放地址法中,若数据不能直接放在由哈希函数计算出来的数组下标所指的单元时,就要寻找数组的其他位置。下面要探索开放地址法的三种方法,它们在找下一个空白单元时使用的方法不同。这三种方法分别是线性探测,二次探测和再哈希法。
线性探测
在线性探测中,线性地查找空白单元。如果5421是要插入数据的位置,它已经被占用了,那么就使用5422,然后是5423,依次类推,数组下标一直递增,直到找到空位。
聚集
当哈希表变得越来越满时,聚集变得越来越严重。这导致产生非常长的探测长度。意味着存取序列最后的单元会非常耗时。
二次探测
在开放地址法的线性探测中会发生聚集。一旦聚集形成,它会变得越来越大。那些哈希化后的落在聚集范围内的数据项,都要一步一步移动,并且插在聚集的最后,因此使聚集变得更大。聚集越大,它增大得也越快。二次探测是防止聚集产生的一种尝试。思想是探测相隔较远的单元,而不是和原始位置相邻的单元。步骤是步数的平方。在线性探测中,如果哈希函数计算的原始下标是x,线性探测就是x+1,x+2,x+3,依次类推。而在二次探测中,探测的过程是x+1,x+4,x+9,x+16,x+25,依次类推。
二次探测的问题
二次探测消除了在线性探测中产生的聚集问题,这种聚集问题叫做原始聚集。然而,二次探测产生了另外一种,更细的聚集问题。之所以会发生,是因为所有映射到同一个位置的关键字在寻找空位时,探测的单元都是一样的。比如将184,302,420和544依次插入到表中,它们都映射到7。那么302需要以一为步长的探测,420需要以四为步长的探测,544需要以九为步长的探测。只要有一项,其关键字映射到7,就需要更长步长的探测。这个现象叫做二次聚集。
// to run this program: C:>java HashTableApp
import java.io.*;
class DataItem
{ // (could have more data)
private int iData; // data item (key)
public DataItem(int ii) // constructor
{ iData = ii; }
public int getKey()
{ return iData; }
}
class HashTable
{
private DataItem[] hashArray; // array holds hash table
private int arraySize;
private DataItem nonItem; // for deleted items
public HashTable(int size) // constructor
{
arraySize = size;
hashArray = new DataItem[arraySize];
nonItem = new DataItem(-1); // deleted item key is -1
}
public void displayTable()
{
System.out.print("Table: ");
for(int j=0; j<arraySize; j++)
{
if(hashArray[j] != null)
System.out.print(hashArray[j].getKey() + " ");
else
System.out.print("** ");
}
System.out.println("");
}
public int hashFunc(int key)
{
return key % arraySize; // hash function
}
public void insert(DataItem item) // insert a DataItem
// (assumes table not full)
{
int key = item.getKey(); // extract key
int hashVal = hashFunc(key); // hash the key
// until empty cell or -1,
while(hashArray[hashVal] != null &&
hashArray[hashVal].getKey() != -1)
{
++hashVal; //线性探测法 // go to next cell
//hashVal+=i*i 二次探测法
hashVal %= arraySize; // wraparound if necessary
}
hashArray[hashVal] = item; // insert item
}
public DataItem delete(int key) // delete a DataItem
{
int hashVal = hashFunc(key); // hash the key
while(hashArray[hashVal] != null) // until empty cell,
{ // found the key?
if(hashArray[hashVal].getKey() == key)
{
DataItem temp = hashArray[hashVal]; // save item
hashArray[hashVal] = nonItem; // delete item
return temp; // return item
}
++hashVal; //线性探测法 // go to next cell
//hashVal+=i*i 二次探测法
hashVal %= arraySize; // wraparound if necessary
}
return null; // can't find item
}
public DataItem find(int key) // find item with key
{
int hashVal = hashFunc(key); // hash the key
while(hashArray[hashVal] != null) // until empty cell,
{ // found the key?
if(hashArray[hashVal].getKey() == key)
return hashArray[hashVal]; // yes, return item
++hashVal; //线性探测法 // go to next cell
//hashVal+=i*i 二次探测法
hashVal %= arraySize; // wraparound if necessary
}
return null; // can't find item
}
}
class HashTableApp
{
public static void main(String[] args) throws IOException
{
DataItem aDataItem;
int aKey, size, n, keysPerCell;
// get sizes
System.out.print("Enter size of hash table: ");
size = getInt();
System.out.print("Enter initial number of items: ");
n = getInt();
keysPerCell = 10;
// make table
HashTable theHashTable = new HashTable(size);
for(int j=0; j<n; j++) // insert data
{
aKey = (int)(java.lang.Math.random() *
keysPerCell * size);
aDataItem = new DataItem(aKey);
theHashTable.insert(aDataItem);
}
while(true) // interact with user
{
System.out.print("Enter first letter of ");
System.out.print("show, insert, delete, or find: ");
char choice = getChar();
switch(choice)
{
case 's':
theHashTable.displayTable();
break;
case 'i':
System.out.print("Enter key value to insert: ");
aKey = getInt();
aDataItem = new DataItem(aKey);
theHashTable.insert(aDataItem);
break;
case 'd':
System.out.print("Enter key value to delete: ");
aKey = getInt();
theHashTable.delete(aKey);
break;
case 'f':
System.out.print("Enter key value to find: ");
aKey = getInt();
aDataItem = theHashTable.find(aKey);
if(aDataItem != null)
{
System.out.println("Found " + aKey);
}
else
System.out.println("Could not find " + aKey);
break;
default:
System.out.print("Invalid entry\n");
}
}
}
public static String getString() throws IOException
{
InputStreamReader isr = new InputStreamReader(System.in);
BufferedReader br = new BufferedReader(isr);
String s = br.readLine();
return s;
}
public static char getChar() throws IOException
{
String s = getString();
return s.charAt(0);
}
public static int getInt() throws IOException
{
String s = getString();
return Integer.parseInt(s);
}
}
再哈希法
为了消除原始聚集和二次聚集,可以使用另外的一个方法:再哈希法。二次聚集产生的原因是,二次探测的算法产生的探测序列步长总是固定的:1,4,9,16,依次类推。现在需要的一种方法是产生一种依赖关键字的探测序列,而不是每个关键字都一样。那么不同的关键字即使映射到相同的数组下标,也可以使用不同的探测序列。方法是把关键字用不同的哈希函数在做一遍哈希化,用这个结果作为步长,对指定的关键字,步长在整个探测中是不变的,不过不同的关键字使用不同的步长。
第二个哈希函数必须具备如下特点:和第一个哈希函数不同,不能输出为0(算法会陷入死循环)
stepSize = constant - (key % constant)会工作的比较好。
// to run this program: C:>java HashDoubleApp import java.io.*; class DataItem { // (could have more items) private int iData; // data item (key) public DataItem(int ii) // constructor { iData = ii; } public int getKey() { return iData; } } class HashTable { private DataItem[] hashArray; // array is the hash table private int arraySize; private DataItem nonItem; // for deleted items HashTable(int size) // constructor { arraySize = size; hashArray = new DataItem[arraySize]; nonItem = new DataItem(-1); } public void displayTable() { System.out.print("Table: "); for(int j=0; j<arraySize; j++) { if(hashArray[j] != null) System.out.print(hashArray[j].getKey()+ " "); else System.out.print("** "); } System.out.println(""); } public int hashFunc1(int key) { return key % arraySize; } public int hashFunc2(int key) { // non-zero, less than array size, different from hF1 // array size must be relatively prime to 5, 4, 3, and 2 return 5 - key % 5; } // insert a DataItem public void insert(int key, DataItem item) // (assumes table not full) { int hashVal = hashFunc1(key); // hash the key int stepSize = hashFunc2(key); // get step size // until empty cell or -1 while(hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1) { hashVal += stepSize; // add the step hashVal %= arraySize; // for wraparound } hashArray[hashVal] = item; // insert item } public DataItem delete(int key) // delete a DataItem { int hashVal = hashFunc1(key); // hash the key int stepSize = hashFunc2(key); // get step size while(hashArray[hashVal] != null) // until empty cell, { // is correct hashVal? if(hashArray[hashVal].getKey() == key) { DataItem temp = hashArray[hashVal]; // save item hashArray[hashVal] = nonItem; // delete item return temp; // return item } hashVal += stepSize; // add the step hashVal %= arraySize; // for wraparound } return null; // can't find item } public DataItem find(int key) // find item with key // (assumes table not full) { int hashVal = hashFunc1(key); // hash the key int stepSize = hashFunc2(key); // get step size while(hashArray[hashVal] != null) // until empty cell, { // is correct hashVal? if(hashArray[hashVal].getKey() == key) return hashArray[hashVal]; // yes, return item hashVal += stepSize; // add the step hashVal %= arraySize; // for wraparound } return null; // can't find item } } class HashDoubleApp { public static void main(String[] args) throws IOException { int aKey; DataItem aDataItem; int size, n; // get sizes System.out.print("Enter size of hash table: "); size = getInt(); System.out.print("Enter initial number of items: "); n = getInt(); // make table HashTable theHashTable = new HashTable(size); for(int j=0; j<n; j++) // insert data { aKey = (int)(java.lang.Math.random() * 2 * size); aDataItem = new DataItem(aKey); theHashTable.insert(aKey, aDataItem); } while(true) // interact with user { System.out.print("Enter first letter of "); System.out.print("show, insert, delete, or find: "); char choice = getChar(); switch(choice) { case 's': theHashTable.displayTable(); break; case 'i': System.out.print("Enter key value to insert: "); aKey = getInt(); aDataItem = new DataItem(aKey); theHashTable.insert(aKey, aDataItem); break; case 'd': System.out.print("Enter key value to delete: "); aKey = getInt(); theHashTable.delete(aKey); break; case 'f': System.out.print("Enter key value to find: "); aKey = getInt(); aDataItem = theHashTable.find(aKey); if(aDataItem != null) System.out.println("Found " + aKey); else System.out.println("Could not find " + aKey); break; default: System.out.print("Invalid entry\n"); } } } public static String getString() throws IOException { InputStreamReader isr = new InputStreamReader(System.in); BufferedReader br = new BufferedReader(isr); String s = br.readLine(); return s; } public static char getChar() throws IOException { String s = getString(); return s.charAt(0); } public static int getInt() throws IOException { String s = getString(); return Integer.parseInt(s); } }
链地址法
开放地址法中,通过在哈希表中再寻找一个空位解决冲突问题。另一个方法是在哈希表每个单元中设置链表。某个数据项的关键字值还是像通常一样映射到哈希表的单元,而数据项本身插入到这个单元的链表中。其他同样映射到这个位置的数据项只需要加到链表中,不需要在原始的数组中寻找空位。链地址法中的装填因子(数据项和哈希表容量的比值)与开放地址法的不同。在链地址法中,需要在有N个单元的数组中装入N个或更多的数据项;因此,装填因子一般为1,或比1大。这没有问题,因为,某些位置包含的链表中包含两个或两个以上的数据项。
// to run this program: C:>java HashChainApp import java.io.*; class Link { // (could be other items) private int iData; // data item public Link next; // next link in list public Link(int it) // constructor { iData= it; } public int getKey() { return iData; } public void displayLink() // display this link { System.out.print(iData + " "); } } class SortedList { private Link first; // ref to first list item public void SortedList() // constructor { first = null; } public void insert(Link theLink) // insert link, in order { int key = theLink.getKey(); Link previous = null; // start at first Link current = first; // until end of list, while( current != null && key > current.getKey() ) { // or current > key, previous = current; current = current.next; // go to next item } if(previous==null) // if beginning of list, first = theLink; // first --> new link else // not at beginning, previous.next = theLink; // prev --> new link theLink.next = current; // new link --> current } public void delete(int key) // delete link { // (assumes non-empty list) Link previous = null; // start at first Link current = first; // until end of list, while( current != null && key != current.getKey() ) { // or key == current, previous = current; current = current.next; // go to next link } // disconnect link if(previous==null) // if beginning of list first = first.next; // delete first link else // not at beginning previous.next = current.next; // delete current link } public Link find(int key) // find link { Link current = first; // start at first // until end of list, while(current != null && current.getKey() <= key) { // or key too small, if(current.getKey() == key) // is this the link? return current; // found it, return link current = current.next; // go to next item } return null; // didn't find it } public void displayList() { System.out.print("List (first-->last): "); Link current = first; // start at beginning of list while(current != null) // until end of list, { current.displayLink(); // print data current = current.next; // move to next link } System.out.println(""); } } class HashTable { private SortedList[] hashArray; // array of lists private int arraySize; public HashTable(int size) // constructor { arraySize = size; hashArray = new SortedList[arraySize]; // create array for(int j=0; j<arraySize; j++) // fill array hashArray[j] = new SortedList(); // with lists } public void displayTable() { for(int j=0; j<arraySize; j++) // for each cell, { System.out.print(j + ". "); // display cell number hashArray[j].displayList(); // display list } } public int hashFunc(int key) // hash function { return key % arraySize; } public void insert(Link theLink) // insert a link { int key = theLink.getKey(); int hashVal = hashFunc(key); // hash the key hashArray[hashVal].insert(theLink); // insert at hashVal } public void delete(int key) // delete a link { int hashVal = hashFunc(key); // hash the key hashArray[hashVal].delete(key); // delete link } public Link find(int key) // find link { int hashVal = hashFunc(key); // hash the key Link theLink = hashArray[hashVal].find(key); // get link return theLink; // return link } } public class HashChainApp { public static void main(String[] args) throws IOException { int aKey; Link aDataItem; int size, n, keysPerCell = 100; // get sizes System.out.print("Enter size of hash table: "); size = getInt(); System.out.print("Enter initial number of items: "); n = getInt(); // make table HashTable theHashTable = new HashTable(size); for(int j=0; j<n; j++) // insert data { aKey = (int)(java.lang.Math.random() * keysPerCell * size); aDataItem = new Link(aKey); theHashTable.insert(aDataItem); } while(true) // interact with user { System.out.print("Enter first letter of "); System.out.print("show, insert, delete, or find: "); char choice = getChar(); switch(choice) { case 's': theHashTable.displayTable(); break; case 'i': System.out.print("Enter key value to insert: "); aKey = getInt(); aDataItem = new Link(aKey); theHashTable.insert(aDataItem); break; case 'd': System.out.print("Enter key value to delete: "); aKey = getInt(); theHashTable.delete(aKey); break; case 'f': System.out.print("Enter key value to find: "); aKey = getInt(); aDataItem = theHashTable.find(aKey); if(aDataItem != null) System.out.println("Found " + aKey); else System.out.println("Could not find " + aKey); break; default: System.out.print("Invalid entry\n"); } } } public static String getString() throws IOException { InputStreamReader isr = new InputStreamReader(System.in); BufferedReader br = new BufferedReader(isr); String s = br.readLine(); return s; } public static char getChar() throws IOException { String s = getString(); return s.charAt(0); } public static int getInt() throws IOException { String s = getString(); return Integer.parseInt(s); } }
使用质数作为取模的基数
如果许多关键字共享一个数组容量作为除数,它们会趋向于映射到相同的位置,这会导致聚集。使用质数,可以消除这种可能性。使用质数可以保证关键字会较平均地映射到数组中。
public static int getPrime(int min){
for(int j = min+1;true; j++)
if(isPrime(j))
return j;
}
public static boolean isPrime(int n){
for(int j=2;(j*j<=n);j++)
if(n %j == 0)
return false;
return true;
}
扩展数组
当哈希表变得太满时,一个选择是扩展数组。在java中,数组有固定的大小,而且不能扩展。编程时只能另外创建一个新的更大的数组,然后把旧数组的所有内容插入到新的数组中。哈希函数根据数组大小计算给定数据项的位置,所以这些数据项不能再放在新数组中和老数组相同的位置上。因此,不能简单地从一个数组向另一个数组拷贝数据。需要按顺序遍历老数组,用insert()方法向新数组中插入每个数据项。这叫做重新哈希化。这是一个耗时的过程,但如果数组要进行扩展,这个过程就是必要的。
哈希化字符串
把短小的字符串转换成数字,方法是每个数位乘以对应的一个常数的幂。cats转化为一个数字,key=3*273 + 1*272 +20*271 +19*270
public static long hashFunc2(String key){
long hashVal = key.charAt(0) - 96;
for(int j=1;j<key.length();j++){
int letter = key.charAt(j) - 96;
hashVal = hashVal*27 + letter;
hashVal %=101;//先求余,而不是最后一次求余,防止溢出
}
return hashVal%101;
}
哈希化和外部存储
文件指针表
外部哈希化的关键部分是一个哈希表,它包含块成员,指向外部存储器中的块。哈希表有时叫做索引。它可以存储在内存中,如果它太大,也可以存储在磁盘上,而把它的一部分放在内存中。即使把哈希表都放在内存中,也要在磁盘中保存一个备份,文件打开时,把它读入内存。
未满的块
在外部哈希化中,重要的是块不要填满。因此,每个块平均存储8个记录。有的块多些,有的块少些。所有关键字映射为同一个值的记录都定位到相同块。为找到特定关键字的一个记录,搜索算法哈希化关键字,用哈希值作为哈希表的下标,得到某个下标中的块号,然后读取这个块。这个过程是有效的,因为定位一个特定的数据项,只需要访问一次块。缺点是相当多的磁盘空间被浪费了,因为设计时规定,块是不允许填满的。为了实现这个方案,必须仔细选择哈希函数和哈希表的大小,为的是限制映射到相同的值关键字的数量。
填满的块
即使一个好的哈希函数,块偶尔也会填满。这时,可以使用在内部哈希表中讨论的处理冲突的不同方法:开放地址法和链地址法。
开放地址法中,插入时,如果发现一个块是满的,算法在相邻的块插入新记录。在线性探测中,这是下一个块,但也可以用二次探测或再哈希法选择。在链地址法中,有一个溢出块,当发现已满时,新纪录插在溢出块中。填满的块是不合需要的,因为有了它就需要额外的磁盘访问,这就需要两倍的访问时间。然而,如果这种情况不经常发生,也可以接受。
小结
哈希表基于数组。
关键字值的范围通常比数组容量大。
关键字值通过哈希函数映射为数组的下标。
英文字典是一个数据库的典型列子,它可以有效的用哈希表来处理。
一个关键字哈希化到已占用的数组单元,这种情况叫做冲突。
冲突可以用两种方法解决:开放地址法和链地址法。
在开放地址法中,把冲突的数据项放在数组的其他位置。
在链地址法中,每个数组单元包含一个链表。把所有映射到同一个数组下标的数据项都插在这个链表中。
讨论了三种开放地址法:线性探测、二次探测和再哈希法。
在线性探测中,步长总是1,所以如果x是哈希函数计算得到的数组下标,那么探测序列就是x,x+1,x+2,x+3,依次类推。
找到一个特定项需要经过的步数叫做探测长度。
在线性探测中,已填充单元的长度不断增加。它们叫做首次聚集,这会降低哈希表的性能。
二次探测中,x的位移是步数的平方,所以探测序列就是x,x+1,x+4,x+9,x+16,依次类推。
二次探测消除了首次聚集,但是产生了二次聚集,它比首次聚集的危害略小。
二次聚集的发生是因为所有映射到同一个单元的关键字,在探测过程中执行了相同的序列。
发生上述情况是因为步长只依赖于哈希值,与关键字无关。
在再哈希法中,步长依赖于关键字,且从第二个哈希函数中得到。
在再哈希法中,如果第二个哈希函数返回一个值s,那么探测序列就是x,x+s,x+2s,x+3s,依次类推,这里s由关键字得到,但探测过程中保持常量。
装填因子是表中数据项数和数组容量的比值。
开放地址法中的最大装填因子应该在0.5附近,若具有相同的装填因子,对于再哈希法来说,查找的平均探测长度是2。
在开放地址法中,当装填因子接近1时,查找时间趋于无限。
在开放地址法中,关键是哈希表不能填的太满。
对于链地址法,装填因子为1比较合适。这时,成功的探测长度平均是1.5,不成功的是2.0。
字符串可以这样哈希化,每个字符乘以常数的不同次幂,求和,然后用取模操作符%缩减结果,以适应哈希表的容量。
如果在Horner方法中用多项式表达哈希化,每一步中都应用取模操作符,以免发生溢出。
哈希表的容量通常是一个质数。这在二次探测和再哈希法中非常重要。
哈希表可用于外部存储,一种做法是用哈希表的单元存储磁盘文件的块号码。