数据结构与算法07 之哈希表

哈希表也称为散列表,是根据关键字值(key value)而直接进行访问的数据结构。也就是说,它通过把关键字值映射到一个位置来访问记录,以加快查找的速度。这个映射函数称为哈希函数(也称为散列函数),映射过程称为哈希化,存放记录的数组叫做散列表。比如我们可以用下面的方法将关键字映射成数组的下标:arrayIndex = hugeNumber % arraySize。

哈希化之后难免会产生一个问题,那就是对不同的关键字,可能得到同一个散列地址,即同一个数组下标,这种现象称为冲突,那么我们该如何去处理冲突呢?一种方法是开放地址法,即通过系统的方法找到数组的另一个空位,把数据填入,而不再用哈希函数得到的数组下标,因为该位置已经有数据了;另一种方法是创建一个存放链表的数组,数组内不直接存储数据,这样当发生冲突时,新的数据项直接接到这个数组下标所指的链表中,这种方法叫做链地址法。下面针对这两种方法进行讨论。

对于开放地址法,首先我们看线性探测法,所谓线性探测,即线性地查找空白单元。如果21是要插入数据的位置,但是它已经被占用了,那么就是用22,然后23,以此类推。数组下标一直递增,直到找到空白位。下面是基于线性探测法的哈希表实现代码:

public class HashTable {
	private DataItem[] hashArray; //DateItem类是数据项,封装数据信息
	private int arraySize;
	private int itemNum; //数组中目前存储了多少项
	private DataItem nonItem; //用于删除项的
	public HashTable() {
		arraySize = 13;
		hashArray = new DataItem[arraySize];
		nonItem = new DataItem(-1); //deleted item key is -1
	}
	public boolean isFull() {
		return (itemNum == arraySize);
	}
	public boolean isEmpty() {
		return (itemNum == 0);
	}
	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 hashFunction(int key) {
		return key % arraySize;  	//hash function
	}

	public void insert(DataItem item) {
		if(isFull()) {
			//扩展哈希表
			System.out.println("哈希表已满,重新哈希化..");
			extendHashTable();
		}
		int key = item.getKey();
		int hashVal = hashFunction(key);
		while(hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1) {
			++hashVal;
			hashVal %= arraySize;
		}
		hashArray[hashVal] = item;
		itemNum++;
	}
	/*
	 * 数组有固定的大小,而且不能扩展,所以扩展哈希表只能另外创建一个更大的数组,然后把旧数组中的数据插到新的数组中。但是哈希表是根据数组大小计算给定数据的位置的,所以这些数据项不能再放在新数组中和老数组相同的位置上,因此不能直接拷贝,需要按顺序遍历老数组,并使用insert方法向新数组中插入每个数据项。这叫重新哈希化。这是一个耗时的过程,但如果数组要进行扩展,这个过程是必须的。
	 */
	public void extendHashTable() { //扩展哈希表
		int num = arraySize;
		itemNum = 0; //重新记数,因为下面要把原来的数据转移到新的扩张的数组中
		arraySize *= 2; //数组大小翻倍
		DataItem[] oldHashArray = hashArray;
		hashArray = new DataItem[arraySize];
		for(int i = 0; i < num; i++) {
			insert(oldHashArray[i]);
		}
	}
	public DataItem delete(int key) {
		if(isEmpty()) {
			System.out.println("Hash table is empty!");
			return null;
		}
		int hashVal = hashFunction(key);
		while(hashArray[hashVal] != null) {
			if(hashArray[hashVal].getKey() == key) {
				DataItem temp = hashArray[hashVal];
				hashArray[hashVal] = nonItem; //nonItem表示空Item,其key为-1
				itemNum--;
				return temp;
			}
			++hashVal;
			hashVal %= arraySize;
		}
		return null;
	}

	public DataItem find(int key) {
		int hashVal = hashFunction(key);
		while(hashArray[hashVal] != null) {
			if(hashArray[hashVal].getKey() == key) {
				return hashArray[hashVal];
			}
			++hashVal;
			hashVal %= arraySize;
		}
		return null;
	}
}
class DataItem {
	private int iData;
	public DataItem (int data) {
		iData = data;
	}
	public int getKey() {
		return iData;
	}
}

线性探测有个弊端,即数据可能会发生聚集。一旦聚集形成,它会变得越来越大,那些哈希化后落在聚集范围内的数据项,都要一步步的移动,并且插在聚集的最后,因此使聚集变得更大。聚集越大,它增长的也越快。这就导致了哈希表的某个部分包含大量的聚集,而另一部分很稀疏。

为了解决这个问题,我们可以使用二次探测:二次探测是防止聚集产生的一种方式,思想是探测相隔较远的单元,而不是和原始位置相邻的单元。线性探测中,如果哈希函数计算的原始下标是x, 线性探测就是x+1, x+2, x+3, 以此类推;而在二次探测中,探测的过程是x+1, x+4, x+9, x+16,以此类推,到原始位置的距离是步数的平方。二次探测虽然消除了原始的聚集问题,但是产生了另一种更细的聚集问题,叫二次聚集:比如讲184,302,420和544依次插入表中,它们的映射都是7,那么302需要以1为步长探测,420需要以4为步长探测,
544需要以9为步长探测。只要有一项其关键字映射到7,就需要更长步长的探测,这个现象叫做二次聚集。二次聚集不是一个严重的问题,但是二次探测不会经常使用,因为还有好的解决方法,比如再哈希法。

为了消除原始聚集和二次聚集,现在需要的一种方法是产生一种依赖关键字的探测序列,而不是每个关键字都一样。即:不同的关键字即使映射到相同的数组下标,也可以使用不同的探测序列。再哈希法就是把关键字用不同的哈希函数再做一遍哈希化,用这个结果作为步长,对于指定的关键字,步长在整个探测中是不变的,不同关键字使用不同的步长、经验说明,第二个哈希函数必须具备如下特点:

1. 和第一个哈希函数不同;

2. 不能输出0(否则没有步长,每次探索都是原地踏步,算法将进入死循环)。

专家们已经发现下面形式的哈希函数工作的非常好:stepSize = constant - key % constant; 其中constant是质数,且小于数组容量。

再哈希法要求表的容量是一个质数,假如表长度为15(0-14),非质数,有一个特定关键字映射到0,步长为5,则探测序列是0,5,10,0,5,10,以此类推一直循环下去。算法只尝试这三个单元,所以不可能找到某些空白单元,最终算法导致崩溃。如果数组容量为13, 质数,探测序列最终会访问所有单元。即0,5,10,2,7,12,4,9,1,6,11,3,一直下去,只要表中有一个空位,就可以探测到它。下面看看再哈希法的代码:

public class HashDouble {
	private DataItem[] hashArray;
	private int arraySize;
	private int itemNum;
	private DataItem nonItem;
	public HashDouble() {
		arraySize = 13;
		hashArray = new DataItem[arraySize];
		nonItem = new DataItem(-1);
	}
	public void displayTable() {
		System.out.print("Table:");
		for(int i = 0; i < arraySize; i++) {
			if(hashArray[i] != null) {
				System.out.print(hashArray[i].getKey() + " ");
			}
			else {
				System.out.print("** ");
			}
		}
		System.out.println("");
	}
	public int hashFunction1(int key) { //first hash function
		return key % arraySize;
	}

	public int hashFunction2(int key) { //second hash function
		return 5 - key % 5;
	}

	public boolean isFull() {
		return (itemNum == arraySize);
	}
	public boolean isEmpty() {
		return (itemNum == 0);
	}
	public void insert(DataItem item) {
		if(isFull()) {
			System.out.println("哈希表已满,重新哈希化..");
			extendHashTable();
		}
		int key = item.getKey();
		int hashVal = hashFunction1(key);
		int stepSize = hashFunction2(key); //用hashFunction2计算探测步数
		while(hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1) {
			hashVal += stepSize;
			hashVal %= arraySize; //以指定的步数向后探测
		}
		hashArray[hashVal] = item;
		itemNum++;
	}
	public void extendHashTable() {
		int num = arraySize;
		itemNum = 0; //重新记数,因为下面要把原来的数据转移到新的扩张的数组中
		arraySize *= 2; //数组大小翻倍
		DataItem[] oldHashArray = hashArray;
		hashArray = new DataItem[arraySize];
		for(int i = 0; i < num; i++) {
			insert(oldHashArray[i]);
		}
	}
	public DataItem delete(int key) {
		if(isEmpty()) {
			System.out.println("Hash table is empty!");
			return null;
		}
		int hashVal = hashFunction1(key);
		int stepSize = hashFunction2(key);
		while(hashArray[hashVal] != null) {
			if(hashArray[hashVal].getKey() == key) {
				DataItem temp = hashArray[hashVal];
				hashArray[hashVal] = nonItem;
				itemNum--;
				return temp;
			}
hashVal += stepSize;
			hashVal %= arraySize;
		}
		return null;
	}
	public DataItem find(int key) {
		int hashVal = hashFunction1(key);
		int stepSize = hashFunction2(key);
		while(hashArray[hashVal] != null) {
			if(hashArray[hashVal].getKey() == key) {
				return hashArray[hashVal];
			}
			hashVal += stepSize;
			hashVal %= arraySize;
		}
		return null;
	}
}

在开放地址法中,通过再哈希法寻找一个空位解决冲突问题,另一个方法是在哈希表每个单元中设置链表(即链地址法),某个数据项的关键字值还是像通常一样映射到哈希表的单元,而数据项本身插入到这个单元的链表中。其他同样映射到这个位置的数据项只需要加到链表中,不需要在原始的数组中寻找空位。下面看看链地址法的代码:

public class HashChain {
	private SortedList[] hashArray; //数组中存放链表
	private int arraySize;
	public HashChain(int size) {
		arraySize = size;
		hashArray = new SortedList[arraySize];
		//new出每个空链表初始化数组
		for(int i = 0; i < arraySize; i++) {
			hashArray[i] = new SortedList();
		}
	}
	public void displayTable() {
		for(int i = 0; i < arraySize; i++) {
			System.out.print(i + ": ");
			hashArray[i].displayList();
		}
	}
	public int hashFunction(int key) {
		return key % arraySize;
	}
	public void insert(LinkNode node) {
		int key = node.getKey();
		int hashVal = hashFunction(key);
		hashArray[hashVal].insert(node); //直接往链表中添加即可
	}
	public LinkNode delete(int key) {
		int hashVal = hashFunction(key);
		LinkNode temp = find(key);
		hashArray[hashVal].delete(key);//从链表中找到要删除的数据项,直接删除
		return temp;
	}

	public LinkNode find(int key) {
		int hashVal = hashFunction(key);
		LinkNode node = hashArray[hashVal].find(key);
		return node;
	}
}

下面是链表类的代码,用的是有序链表:

public class SortedList {
	private LinkNode first;
	public SortedList() {
		first = null;
	}
	public boolean isEmpty() {
		return (first == null);
	}
	public void insert(LinkNode node) {
		int key = node.getKey();
		LinkNode previous = null;
		LinkNode current = first;
		while(current != null && current.getKey() < key) {
			previous = current;
			current = current.next;
		}
		if(previous == null) {
			first = node;
		}
		else {
			node.next = current;
			previous.next = node;
		}
	}
	public void delete(int key) {
		LinkNode previous = null;
		LinkNode current = first;
		if(isEmpty()) {
			System.out.println("chain is empty!");
			return;
		}
		while(current != null && current.getKey() != key) {
			previous = current;
			current = current.next;
		}
		if(previous == null) {
			first = first.next;
		}
		else {
			previous.next = current.next;
		}
	}
	public LinkNode find(int key) {
		LinkNode current = first;
		while(current != null && current.getKey() <= key) {
			if(current.getKey() == key) {
				return current;
			}
			current = current.next;
		}
		return null;
	}
	public void displayList() {
		System.out.print("List(First->Last):");
		LinkNode current = first;
		while(current != null) {
			current.displayLink();
			current = current.next;
		}
		System.out.println("");
	}
}
class LinkNode {
	private int iData;
	public LinkNode next;
	public LinkNode(int data) {
		iData = data;
	}
	public int getKey() {
		return iData;
	}
	public void displayLink() {
		System.out.print(iData + " ");
	}
}

在没有冲突的情况下,哈希表中执行插入和删除操作可以达到O(1)的时间级,这是相当快的,如果发生冲突了,存取时间就依赖后来的长度,查找或删除时也得挨个判断,但是最差也就O(N)级别。

时间: 2024-07-30 09:13:41

数据结构与算法07 之哈希表的相关文章

算法6-4:哈希表现状

战争故事 非常久非常久以前,以前发生过非常多关于哈希函数的战争故事. 那些战争的基本原理就是通过精心构造造成大量的哈希冲突从而占用大量的CPU资源. 被攻击的软件例有下面样例: 带有漏洞的server:攻击者精心构造哈系冲突.仅仅须要56K的网速就能让server死机,从而达到DOS攻击的目的. Perl 5.8.0:攻击者精心构造哈系冲突插入到关联数组中 Linux 2.4.20 内核:攻击者精心构造文件名称,造成大量哈系冲突从而让系统性能骤降. 攻击原理 在Java中的String对象非常e

数据结构与算法----散列/哈希

1. 简介 散列表的实现叫散列hashing,散列用于以常数平均时间执行 插入.删除.查找,不支持排序.findMin.findMax. 查找关键字不需要 比较 在一个记录的存储位置和它的关键字之间建立映射关系:key--f(key)   这个关系就是散列函数/哈希函数.将一些记录存储在一块 连续 的存储空间,这块空间就是散列表/哈希表. 与线性表.树.图比较: 数据元素之间没有什么逻辑关系,也不能用连线图表示出来. 问题: 关键字不同,但通过散列函数计算的结果相同,即出现了冲突 collisi

大话数据结构—散列表查找(哈希表)

一.基本概念 散列技术:在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key). f:散列函数/哈希函数: 采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表. 关键字对应的记录存储位置称为散列地址. 散列技术既是一种存储方法,也是一种查找方法. 散列技术适合求解问题是查找与给定值相等的记录.查找速度快. 散列技术不适合范围查找,不适合查找同样关键字的记录,不适合获取记录的排序,最值. 冲突:关键字key1不等于k

算法练习--二分搜索哈希表-JS 实现

1. 以哈希KEY的值建立二叉哈希表 2. 根据传入的哈希值使用二分法搜索 具体实现如下: function binarySearchTable(comp){ this.comp = comp; this.kv = new Array(); } binarySearchTable.prototype.add = function(k,v){ if(this.kv.length == 0 || this.comp(this.kv[0].key,k) >= 0){ this.kv.splice(0,

数据结构与算法(三)-线性表之静态链表

前言:前面介绍的线性表的顺序存储结构和链式存储结构中,都有对对象地引用或指向,也就是编程语言中有引用或者指针,那么在没有引用或指针的语言中,该怎么实现这个的数据结构呢? 一.简介 定义:用数组代替指针或引用来描述单链表,即用数组描述的链表叫做静态链表,这种描述方法叫做游标实现法: 上面的静态链表图有两个数组游标和数据,其中数据数组存储数据,而游标数组存储同下标为数据的下一个数据的下标值,简单模拟一下静态链表遍历的过程: 先查看下标为999的游标数组值:1: 根据游标数组值1,查找下标为1的数据:

数据结构和算法学习总结03 线性表---队列

队列 队列(Queue)是限定只能在表的一端进行插入和在另一端进行删除操作的线性表. 与栈的比较: 1.队列先进先出,栈先进后出. 2.从"数据结构"的角度看,它们都是线性结构,即数据元素之间的关系相同. 但它们是完全不同的数据类型.除了它们各自的基本操作集不同外,主要区别是对插入和删除操作的"限定": 栈是限定只能在表的一端进行插入和删除操作的线性表:队列是限定只能在表的一端进行插入和在另一端进行删除操作的线性表. 队列同样分为顺序队列和链式队列,我们一般用链式队

数据结构和算法学习总结03 线性表---顺序表

线性表是最简单.也是最基本的一种线性数据结构. 它有两种存储表示法:顺序表和链表,最基本的操作是插入.删除和查找等. 顺序表的基本操作如下: #include <stdio.h> #include <stdlib.h> #define EXIST 1 //存在数据元素,不为空 #define EMPTY 0 //为空 #define OK 1 //成功 #define ERROR 0 //出现错误 #define OVERFLOW -1 //元素溢出 const int LIST_

数据结构与算法(四)-线性表之循环链表

前言:前面几篇介绍了线性表的顺序和链式存储结构,其中链式存储结构为单向链表(即一个方向的有限长度.不循环的链表),对于单链表,由于每个节点只存储了向后的指针,到了尾部标识就停止了向后链的操作.也就是说只能向后走,如果走过了,就回不去了,还得重头开始遍历,所以就衍生出了循环链表 一.简介 定义:将单链表中中断结点的指针端有空指针改为指向头结点,就使整个单链表形成一个环,这种头尾详解的单链表称为单循环链表,简称循环链表: 特性: 若链表为空,则头结点的next结点还是指向其本身,即head.next

数据结构和算法学习总结04 线性表---栈

栈 栈(Stack)是特殊的线性表,是只允许在一端进行插入和删除的线性表. 允许插入和删除的叫栈顶,反之则是栈底. 栈的插入称为进栈,删除称为出栈. 特性是:后进先出,所以栈也叫后进先出表,简称LIFO表(Last In First Out). 因为栈是线性表,所以也有顺序表和链表两种形式,一般我们常用顺序表. 从代码中可以看出:与顺序表相比实际上就是插入和删除操作发生了改变. #include <iostream> using namespace std; const int Stack_S