一、散列表查找的基础知识
1.散列表查找的定义
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。查找时,根据这个确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则必定存在在f(key)的位置上。
把对应关系f称为散列函数,又称为哈希(Hash)函数,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash Table)。关键字对应的记录存储位置称为散列地址。
2.散列表查找步骤
散列技术既是一种存储方法,也是一种查找方法。散列过程的步骤分为两步:
- 在存储时,通过散列函数计算记录的散列地址,并按次散列地址存储该记录。
- 当查找记录时,通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。
3.散列表查找的适合场景
散列技术与线性表、树、图结构不同的是,散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联。因此,散列主要是面向查找的存储结构。
散列技术最适合的求解问题是查找与给定值相等的记录。不适合同样的关键字对应很多记录或者范围查找。
对于两个不同的关键字key1≠key2,但是却有f(key1)=f(key2),这种现象称为冲突,并把key1和key2称为这个散列函数的同义词。
二、散列函数的构造方法
散列函数设计的原则:1.计算简单、 2.散列地址分布均匀
对于关键字是中文字符或者是因为字符或者是各种符号,都可以转化为某种数字来对待,例如ASCII码或者Unicode码等。
实际应用中,根据不同的情况采用不同的散列函数,给出如下考虑因素:计算散列地址所需的时间;关键字的长度;散列表的大小;关键字的分布情况;记录查找的频率。
1.直接定址法 :f(key) = a X key + b(a,b为常数)
这样的散列函数优点就是简单、均匀也不会产生冲突,但问题是需要事先通知关键字的分布情况,适合查找表较小且连续的情况。
2.数字分析法
抽取方法是使用关键字的一部分来计算散列存储位置的方法,这在散列函数中是常常用到的手段。数字分析法通常适合处理关键字位数比较大的情况,如果实现知道关键字的分布且关键字的若干位分布较均匀,就可以考虑使用这个方法。
3.平方取中法
假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用作散列地址。平方取中法比较适合于不知道关键字的分布,而位数又不是很大的情况。
4.折叠法
折叠法是将关键字从左到右分割成位数相等的几部分,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
例如关键字是9876543210,散列表表长为三位,分成四组叠加求和987+654+321+0=1962,再求后三位得到散列地址为962。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。
5.除留余数法(最常用的散列函数)
对于散列表长为m的散列函数公式为:f(key) = key mod p (p ≤m)。关键在于p值的选取。
p=11时只有12和144冲突。
经验就是,若散列表表长为m,通常p为小于等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
6.随机数法
选择一个随机数,去关键字的随机函数值为它的散列地址。即f(key)=random(key)。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。
三、处理散列冲突的方法
设计得再好的散列函数也不可能完全避免冲突。
1.开放定址法:fi(key) = (f(key) + di) MOD m(di = 1,2,3...,m-1)
开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
假设关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12,f(key) = key MOD 12
则f(12) = 0,f(67) = 7,f(56) = 8,f(16) = 4,f(25) = 1,而f(37) = 1,则f(37) = (f(37) + 1) MOD 12 = 2,继续f(22) = 10,f(29) = 5,f(15) = 3,f(47) = 11
而f(48) = 0,冲突,f(48) = (f(48) + 1) MOD 12 = 1,也冲突,f(48) = (f(48) + 1) MOD 12 = 2,还是冲突,一直到f(48) = (f(48) + 6) MOD 12 = 6时才不冲突。
把这种解决冲突的开放定址法称为线性探测法。
例如48和37这种本来都不是同义词却需要争夺一个地址的情况,称为堆积。堆积使得需要不断处理冲突,无论是存入还是查找效率都会大大降低。
当key=34时,f(key)=10,但是22后面没有空位置了,反而它的前面有一个空位置,尽管可以不断地求余数后得到结果,但效率很差。可以改进di=1²,-1²,2²,-2²,...,q²,-q²(q≤m/2),这样就等于是可以双向寻找到可能的空位置。
增加平方运算的目的是为了不让关键字都聚集在某一块区域,称这种方法为二次探测法。fi(key) = (f(key) + di) MOD m ( di=1²,-1²,2²,-2²,...,q²,-q²(q≤m/2))
还可以对于位移量di采用随机函数计算得到,称之为随机探测法。即设置随机种子相同,每次调用随机函数可以生成不会重复的数列,在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的di可以得到相同的散列地址。fi(key) = (f(key) + di) MOD m(di是一个随机数列)
2.再散列函数法
再散列函数法就是事先准备多个散列函数fi(key) = RHi(key) (i=1,2,...,k),每当发生散列地址冲突时,就换一个散列函数计算,这种方法能够使得关键字不产生聚集,也相应增加了计算的时间。
3.链地址法
将所有关键字为同义词的记录存储在一个单链表中,称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针,无论有多少个冲突,都只是在当前位置给单链表增加结点而已。
例如:0下标→48→12,1下标→37→25...
链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,也带来了查找时需要遍历单链表的性能损耗。
4.公共溢出区法
公共溢出区法就是将所有与之间的关键字位置有冲突的关键字{37,48,34}存入一个公共的溢出区表中。
在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行对比,如果相等,则查找成功;如果不相等,则到溢出表中进行顺序查找。
如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。
四、散列表查找的实现
1.除留取余法与链地址法的散列表查找实现
- 单链表结点类
package bigjun.iplab.hashTable; public class Node { public Object data; // 存放结点的数据元素的数据域(int类型不能设置null,而Integer类型可以)
public Node next; // 存放后继元素的引用 // 可实现初始化一个空的结点
public Node() {
this(null, null);
} // 可实现构造一个数据域值为指定参数值,而指针域为空的结点
public Node(Object data) {
this(data, null);
} // 可实现构造一个数据域和指针域值都为指定参数的结点
public Node(Object data, Node next) {
this.data = data;
this.next = next;
} }
单链表结点类
- 单链表类
package bigjun.iplab.hashTable;
/**
* 带头结点的单链表类
*/
public class LinkList { public Node head; // 单链表的头结点 // 单链表的构造函数
public LinkList() { // 单链表的构造函数
head = new Node(); // 调用无参数构造函数,初始化一个空的结点作为头结点
} // 判断带头结点的单链表是否为空
public boolean isEmpty() {
return head.next == null;
} // 清空带头结点的单链表
public void clear() {
head.data = null;
head.next = null;
} // 求带头结点的单链表的长度
public int getLength() {
int length = 0;
Node pNode = head.next;
while (pNode != null) {
length++;
pNode = pNode.next;
}
return length;
} // 读取带头结点的单链表中第i个结点
public Object getElem(int i) throws Exception {
Node pNode = head.next;
int j = 0;
while (pNode != null && j < i) {
pNode = pNode.next;
++j;
}
if (pNode == null || j>i) {
throw new Exception("第" + i + "个元素不存在");
}
return pNode.data;
} // 在带头结点的单链表中查找值为e的结点并返回结点下标
public int indexof(Object e) {
Node pNode = head.next;
int j = 0;
while (pNode!=null && !pNode.data.equals(e)) {
pNode = pNode.next;
++j;
}
if (pNode != null) {
return j;
} else {
return -1;
}
} // 在带头结点的单链表中的第i个结点之前插入一个值为x的新结点
public void insert(int i, Object x) throws Exception {
Node pNode = head;
int j = -1;
while (pNode != null && j < i - 1) {
pNode = pNode.next;
++j;
}
if (j > i - 1 || pNode ==null)
throw new Exception("插入位置不合法");
Node sNode = new Node(x);
sNode.next = pNode.next;
pNode.next = sNode;
} // 删除带头结点的单链表的第i个结点
public void delete(int i) throws Exception {
Node pNode = head;
int j = -1;
while (pNode.next !=null && j<i - 1) {
pNode = pNode.next;
++j;
}
if (pNode.next == null || j > i - 1) {
throw new Exception("删除位置不合法");
}
pNode.next = pNode.next.next;
} // 遍历带头结点的单链表
public void listTraverse() {
Node node = head.next;
while (node != null) {
System.out.print(node.data + " ");
node = node.next;
}
System.out.println();
} }
单链表实现类
- 散列表实现类
package bigjun.iplab.hashTable;
/**
* 散列表查找,通过除留取余法定义哈希函数,同时通过链地址法解决冲突的问题
* 包括插入、删除、查找和遍历等方法
* Java中的hashCode()方法就是根据一定的规则将与对象相关的信息映射成一个数值,这个数值称作为散列值
* @param <E> 泛型,可以是int,String等其他类型
*/
public class HashTable<E> { public LinkList[] table; // 哈希表的对象数组 public HashTable(int size) { // 构造和要插入的数组长度一致的单链表数组
this.table = new LinkList[size];
for (int i = 0; i < table.length; i++) {
table[i] = new LinkList(); // 构造空单链表
}
} public int hash(int key) { // 除留取余法哈希函数,除数是哈希表的长度
return key % table.length;
} public void insert(E elem) throws Exception { // 向哈希表中插入指定的数据元素
int key = elem.hashCode(); // 计算每个对象的散列值并返回
System.out.println(elem + "的hashCode值为: " + key);
int i = hash(key); // 根据计算得到的int数值计算哈希地址
table[i].insert(0, elem); // 向对应的单链表中插入指定的数据元素
} public void printHashTable() { // 遍历哈希表中各个单链表的数据元素
System.out.println("遍历散列表得到的结果为: ");
for (int i = 0; i < table.length; i++) {
System.out.print("数组下标为" + i + "的table遍历得到: ");
table[i].listTraverse(); // 遍历单链表
}
} public Object search(E elem) throws Exception { // 在哈希表中找到指定对象,若查找成功,返回对象在单链表中的索引,否则,返回null
int key = elem.hashCode();
int i = hash(key);
int index = table[i].indexof(elem);
if (index >= 0) {
return table[i].getElem(index);
} else {
return null;
}
} public boolean contain(E elem) throws Exception { // 判断哈希表中是否包含指定对象
return this.search(elem) != null;
} public boolean remove(E elem) throws Exception { // 在哈希表中删除指定对象,删除成功返回true,否则返回false
int key = elem.hashCode();
int i = hash(key);
int index = table[i].indexof(elem);
if (index >= 0) {
table[i].delete(index);;
return true;
} else {
return false;
}
} public static void main(String[] args) throws Exception {
int[] key = {12,67,56,16,25,37,22,29,15,47,48,34};
HashTable<Integer> hTable = new HashTable<>(key.length);
for (int i = 0; i < key.length; i++) {
hTable.insert(key[i]);
}
System.out.println("**************************************");
hTable.printHashTable();
System.out.println("**************************************");
String[] str = {"A", "B","C","D","Lian","Jiang","hhh"};
HashTable<String> strTable = new HashTable<>(str.length);
for (int i = 0; i < str.length; i++) {
strTable.insert(str[i]);
}
System.out.println("**************************************");
strTable.printHashTable();
System.out.println("散列表中是否包含“Lian”? : " + strTable.contain("Lian"));
System.out.println("**************************************");
System.out.println("是否删除“Lian”成功: " + strTable.remove("Lian"));
System.out.println("**************************************");
strTable.printHashTable();
System.out.println("散列表中是否包含“Lian”? : " + strTable.contain("Lian"));
}
}
- 输出
12的hashCode值为: 12
67的hashCode值为: 67
56的hashCode值为: 56
16的hashCode值为: 16
25的hashCode值为: 25
37的hashCode值为: 37
22的hashCode值为: 22
29的hashCode值为: 29
15的hashCode值为: 15
47的hashCode值为: 47
48的hashCode值为: 48
34的hashCode值为: 34
**************************************
遍历散列表得到的结果为:
数组下标为0的table遍历得到: 48 12
数组下标为1的table遍历得到: 37 25
数组下标为2的table遍历得到:
数组下标为3的table遍历得到: 15
数组下标为4的table遍历得到: 16
数组下标为5的table遍历得到: 29
数组下标为6的table遍历得到:
数组下标为7的table遍历得到: 67
数组下标为8的table遍历得到: 56
数组下标为9的table遍历得到:
数组下标为10的table遍历得到: 34 22
数组下标为11的table遍历得到: 47
**************************************
A的hashCode值为: 65
B的hashCode值为: 66
C的hashCode值为: 67
D的hashCode值为: 68
Lian的hashCode值为: 2368138
Jiang的hashCode值为: 71565339
hhh的hashCode值为: 103272
**************************************
遍历散列表得到的结果为:
数组下标为0的table遍历得到:
数组下标为1的table遍历得到: hhh
数组下标为2的table遍历得到: A
数组下标为3的table遍历得到: Lian B
数组下标为4的table遍历得到: C
数组下标为5的table遍历得到: D
数组下标为6的table遍历得到: Jiang
散列表中是否包含“Lian”? : true
**************************************
是否删除“Lian”成功: true
**************************************
遍历散列表得到的结果为:
数组下标为0的table遍历得到:
数组下标为1的table遍历得到: hhh
数组下标为2的table遍历得到: A
数组下标为3的table遍历得到: B
数组下标为4的table遍历得到: C
数组下标为5的table遍历得到: D
数组下标为6的table遍历得到: Jiang
散列表中是否包含“Lian”? : false
2.除留取余法与开放定址法的线性检测
#include "stdio.h"
#include "stdlib.h"
#include "io.h"
#include "math.h"
#include "time.h" #define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0 #define MAXSIZE 100 /* 存储空间初始分配量 */ #define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 /* 定义散列表长为数组的长度 */
#define NULLKEY -32768 typedef int Status; /* Status是函数的类型,其值是函数结果状态代码,如OK等 */ typedef struct
{
int *elem; /* 数据元素存储基址,动态分配数组 */
int count; /* 当前数据元素个数 */
}HashTable; int m=; /* 散列表表长,全局变量 */ /* 初始化散列表 */
Status InitHashTable(HashTable *H)
{
int i;
m=HASHSIZE;
H->count=m;
H->elem=(int *)malloc(m*sizeof(int));
for(i=;i<m;i++)
H->elem[i]=NULLKEY;
return OK;
} /* 散列函数 */
int Hash(int key)
{
return key % m; /* 除留余数法 */
} /* 插入关键字进散列表 */
void InsertHash(HashTable *H,int key)
{
int addr = Hash(key); /* 求散列地址 */
while (H->elem[addr] != NULLKEY) /* 如果不为空,则冲突 */
{
addr = (addr+) % m; /* 开放定址法的线性探测 */
}
H->elem[addr] = key; /* 直到有空位后插入关键字 */
} /* 散列表查找关键字 */
Status SearchHash(HashTable H,int key,int *addr)
{
*addr = Hash(key); /* 求散列地址 */
while(H.elem[*addr] != key) /* 如果不为空,则冲突 */
{
*addr = (*addr+) % m; /* 开放定址法的线性探测 */
if (H.elem[*addr] == NULLKEY || *addr == Hash(key)) /* 如果循环回到原点 */
return UNSUCCESS; /* 则说明关键字不存在 */
}
return SUCCESS;
} int main()
{
int arr[HASHSIZE]={,,,,,,,,,,,};
int i,p,key,result;
HashTable H; key=; InitHashTable(&H);
for(i=;i<m;i++)
InsertHash(&H,arr[i]); result=SearchHash(H,key,&p);
if (result)
printf("查找 %d 的地址为:%d \n",key,p);
else
printf("查找 %d 失败。\n",key); for(i=;i<m;i++)
{
key=arr[i];
SearchHash(H,key,&p);
printf("查找 %d 的地址为:%d \n",key,p);
} return ;
}
五、散列表查找的性能分析
如果没有冲突,散列查找是效率最高的,因为它的时间复杂度为O(1),然而,冲突时不可避免的。
散列查找的平均查找长度取决于下面这些因素:
- 散列函数是否均匀。散列函数的好坏直接影响着出现冲突的频繁程度,由于不同的散列函数对同一组随机的关键字,产生的冲突的可能性是相同的,因此不考虑散列函数对平均查找长度的影响。
- 处理冲突的方法。相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。例如,线性探测处理冲突可能会产生堆积,显然没有二次探测法好,而链地址法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能。
- 散列表的装填因子。装填因子α=填入表中的记录个数/散列表长度。α标志着散列表的装满的程度。当填入表中的记录越多,α就越大,产生冲突的可能性就越大。即哈希表的平均查找长度是α的函数,而不是表中数据元素个数n的函数。通常都将散列表的空间设置得比查找集合大,此时虽然浪费了一定的空间,换来的是查找效率的大大提升。