哈希表
Hash表也稱散列表,也可以直接譯作哈希表,Hash表是一種根據關鍵字值(key - value)映射到表中的一個位置而直接進行訪問的數據結構,這個映射函數叫散列函數(哈希函數)
(鏈地址法哈希表)
哈希表基於數組實現,通過把關鍵字映射到數組的某個下標(哈希函數)來加快查找速度,查找某個關鍵字對於哈希表來說,只是O(1)的時間級
什麼是散列函數
散列函數:將關鍵字裝換爲數組的特定下標,這種轉換的函數就是哈希函數
若對於關鍵字集合中的任一個關鍵字,經散列函數映象到地址集合中任何一個地址的概率是相等的,則稱此類散列函數爲均勻散列函數(Uniform Hash function)
就如同構建一個字典,先將所有的單詞全部存入內存,散列函數就如同目錄,是單詞與對應地址的映射
一個高效的散列函數可以幫我們快速定位對應的地址,如何設置高效的散列函數?
需要考慮:
- 計算哈希函數所需時間
- 關鍵字的長度、種類
- 哈希表的大小
- 關鍵字的分佈情況
- 記錄的查找頻率
常見的散列函數有六種:
- 直接尋址法:取關鍵字或關鍵字的某個線性函數值爲散列地址,也就是
f(key) = a * key + b
,最基本的散列函數 - 數據分析法:分析一組數據,找出數字的規律,儘可能利用這些數據來構造衝突機率較低的散列地址(如網址都是後幾位不同,就利用後幾位映射地址)
- 平方取中法:當無法確定關鍵字中哪幾位分佈較均勻時,可以先求出關鍵字的平方值,然後按需要取平方值的中間幾位作爲哈希地址
- 摺疊法:將關鍵字分割成位數相同的幾部分,最後一部分位數可以不同,然後取這幾部分的疊加和(去除進位)作爲散列地址
- 隨機數法:選擇一隨機函數,取關鍵字的隨機值作爲散列地址
- 除留餘數法:取關鍵字被某個不大於散列表表長m的數p除後所得的餘數爲散列地址
當關鍵字是非基本數據類型時需要考慮多方面,這裏就不深追,以下的例子以關鍵字爲整形,通過取餘法爲散列函數簡單處理
衝突
取餘法:關鍵字除一個設置的不大於散列表表長的數m,key % m
得到餘數,餘數爲0~9,即將關鍵字映射到了0-9的數組上
把巨大的數字範圍壓縮到較小的數字範圍,那麼肯定會有幾個不同的單詞哈希化到同一個數組下標,即產生了衝突
例如設置m=10,數組爲0~9,1和11都會映射到數組下標爲1的位置,這就產生了衝突
常用兩種方法:
- 開放地址法:當衝突產生時,再給關鍵字找一個地址,有三種方法:線性探測、二次探測以及再哈希法
- 鏈地址法:擴展數組的單元,即數組每個數據項設置爲鏈表或者子數組
開放地址法
開發地址法:當衝突發生時,爲關鍵字再找一個合適的位置
線性探測
當散列函數得到的位置被佔,數組下標依次遞增,直到找到空白的位置
例如:存入1和11,1的位置被佔了,在下標2的位置存放11
以下通過Java實現一個開放地址法的哈希表
將要存放的數據,關鍵字爲int型
class DataItem {
private int iData;
public DataItem(int iData) {
this.iData = iData;
}
public int getKey() {
return iData;
}
}
哈希表,設置了以下重要方法:
- 取餘:除數組大小
- 插入數據項
- 更新數組:新建數據爲原數組大小的兩倍
- 刪除數據項
- 根據關鍵字查找數據項
public class MyHashTable {
//DataItem類,表示每個數據項信息
private DataItem[] hashArray;
//數組的初始大小
private int arraySize;
//數組實際存儲了多少項數據
private int itemNum;
//用於刪除數據項
private DataItem nonItem;
public MyHashTable(int arraySize) {
this.arraySize = arraySize;
hashArray = new DataItem[arraySize];
//刪除的數據項下標初始化爲-1
nonItem = new DataItem(-1);
}
//判斷數組是否存儲滿了
public boolean isFull() {
return (itemNum == arraySize);
}
//判斷數組是否爲空
public boolean isEmpty() {
return (itemNum == 0);
}
//打印數組內容
public void display() {
System.out.println("Table:");
for (int j = 0;j < arraySize;j++) {
if (hashArray[j] != null) {
System.out.println("== 第 "+j+" 項爲:"+hashArray[j].getKey() + " ==");
} else {
System.out.println("== 第 "+j+" 項爲空 ==");
}
}
}
//通過哈希函數轉換得到數組下標
public int hashFunction(int key) {
return key % arraySize;
}
//插入數據項
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) {
//線性探測,直接下標+1
++hashVal;
//防止越界
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 = hashFunction(key);
while (hashArray[hashVal] != null) {
if (hashArray[hashVal].getKey() == key) {
DataItem temp = hashArray[hashVal];
//nonItem表示空Item,其key爲-1
hashArray[hashVal] = nonItem;
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 Test{
public static void main(String[] args) {
MyHashTable myHashTable = new MyHashTable(10);
Scanner scanner = new Scanner(System.in);
while (true){
System.out.println("add:添加數據 display:顯示數據 find:查找 exit:退出");
String key = scanner.next();
switch (key){
case "add":
System.out.println("輸入iData:");
int iData = scanner.nextInt();
DataItem dataItem = new DataItem(iData);
myHashTable.insert(dataItem);
break;
case "display":
myHashTable.display();
break;
case "find":
System.out.println("請輸入要查找的數據項的iData:");
iData = scanner.nextInt();
myHashTable.find(iData);
break;
case "exit":
scanner.close();
System.exit(0);
default:
break;
}
}
}
}
添加3個數據:
打印,可以發現通過取餘分別將數據放置在相應的位置:
再添加一個數據11,與1衝突,按照線性探測,放到下一個位置:
當數據滿時,需要重新哈希化:
數組大小*2:
裝填因子
可以從上面的案例中看出,當數組中數據數量到一定大小後,每次插入數據需要多次計算插入位置,這是比較耗時的
我們需要儘量減少再次計算,就需要引入一個填裝因子
裝填因子:已添加到哈希表內的數據項與表長的比例
例如哈希表大小1000,添加了500個數據項,裝填因子爲0.5
裝填因子表示哈希表中元素填滿的程度,當數據項到達一定程度,數據需要重新計算位置即衝突的機率很大
二次探測
二次探測是爲了防止數據聚集,本質也是線性探測,區別在與設置較遠的探測步長
從散列函數計算的原始下標爲x,線性探測是x+1,x+2,x+3…,而二次探測爲x+1,x+4,x+9,x+16…
相對與線性探測有了一定的改進
再散列法
前面線性探測、二次探測實際上都是固定的步長,我們可以設置一種依賴關鍵字的探測序列
再散列法:把關鍵字用不同的散列函數再做一遍散列化,用這個結果作爲步長,進行探測
第二個散列函數需要:
- 和第一個散列函數不同
- 不能輸出0,不然每次探測會原地踏步,陷入死循環
專家們已經發現下面形式的哈希函數工作的非常好:stepSize = constant - key % constant;
其中constant是質數,且小於數組容量,表的容量要是一個質數
例如:對上面的例子進行修改
創建第二個散列函數:步長 = 7 - key % 7
//第二個散列函數:計算步長
public int hashFunction2(int key){
return 7 - key % 7;
}
//插入數據項
public void insert2(DataItem item) {
if (isFull()) {
//哈希表已滿,擴展哈希表
System.out.println("哈希表已滿,重新哈希化...");
extendHashTable();
}
int key = item.getKey();
int hashVal = hashFunction(key);
//第二散列函數計算步長
int stepSize = hashFunction2(key);
while (hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1) {
//再散列法
hashVal += stepSize;
//防止越界
hashVal %= arraySize;
}
hashArray[hashVal] = item;
itemNum++;
}
修改一下測試類,添加1和11,添加11時,步長爲7-11%7=3
,即在下標爲4的位置加入數據
再散列法因爲由關鍵字決定步長,可以較有效的減少聚集
鏈地址法
把數組的數據項設置爲鏈表或者數組
常用的數組+鏈表
Java實現哈希表:數組+鏈表
這裏再稍微該動:存放的數據爲員工對象,屬性爲id,name
//表示一個員工
class Emp{
public int id;
public String name;
public Emp next;
public Emp(int id, String name) {
this.id = id;
this.name = name;
}
}
這裏的哈希表=數組+鏈表,即需要分開爲兩個類:員工鏈表、哈希表
員工鏈表:
- 添加員工:直接在鏈表尾加上新增員工
- 遍歷:遍歷鏈表打印
- 根據id查找員工
package com.company.hashTable;
import java.util.Scanner;
/**
* @author zfk
* 哈希表
*/
//員工鏈表
class EmpLinkedList{
//頭指針爲第一個Emp
private Emp head;
//添加員工,直接加在最後
public void add(Emp emp){
//如果是添加第一個員工
if (head == null){
head = emp;
return;
}
//如果不是第一個
Emp cur = head;
while (true){
//到了鏈表最後
if (cur.next == null) {
break;
}
cur = cur.next;
}
//退出時cur爲鏈表最後一個元素
cur.next = emp;
}
//遍歷鏈表
public void list(int no){
if (head == null){
System.out.println("=== 第 "+no+" 條鏈表爲空 ===");
return;
}
System.out.println("=====第 "+no+" 條鏈表=======");
Emp cur = head;
while (true){
System.out.print("=> id = "+cur.id+" name = "+cur.name);
//已經是最後的節點
if (cur.next == null){
break;
}
//後移
cur = cur.next;
}
System.out.println();
System.out.println("============");
}
//根據id查找員工
public Emp findEmpById(int id){
//判斷鏈表是否爲空
if (head == null){
System.out.println("=== 當前鏈表爲空 ===");
return null;
}
Emp cur = head;
while (true){
//找到了cur
if (cur.id == id){
break;
}
//遍歷完當前鏈表沒有找到,退出
if (cur.next == null){
cur = null;
break;
}
//後移
cur = cur.next;
}
return cur;
}
}
//哈希表
class HashTable{
private EmpLinkedList[] linkedListArray;
//表示鏈表數
private int size;
public HashTable(int size) {
this.size = size;
this.linkedListArray = new EmpLinkedList[size];
//分別初始化鏈表
for (int i = 0;i < size;i++){
linkedListArray[i] = new EmpLinkedList();
}
}
//添加
public void add(Emp emp){
//根據員工的id得到該員工應當添加到哪個鏈表
int listNum = hashFun(emp.id);
//將emp添加到對應的鏈表
linkedListArray[listNum].add(emp);
}
//散列函數,使用取模法
public int hashFun(int id){
return id % size;
}
//遍歷所有的鏈表
public void list(){
for (int i = 0;i < size;i++){
linkedListArray[i].list(i);
}
}
//根據輸入的id查找員工
public void findEmpById(int id){
//使用散列函數確定應該從哪條鏈表查找
int listNum = hashFun(id);
Emp empById = linkedListArray[listNum].findEmpById(id);
if (empById != null){
System.out.println("在第 "+listNum+" 條鏈表查找到");
}
else {
System.out.println("=== 在哈希表中沒有找到 ===");
}
}
}
測試類:設置哈希表數組大小7
public class HashTableDemo {
public static void main(String[] args) {
//創建一個哈希表
HashTable hashTable = new HashTable(7);
Scanner scanner = new Scanner(System.in);
while (true){
System.out.println("add:添加成員 list:顯示成員 find:查找 exit:退出");
String key = scanner.next();
switch (key){
case "add":
System.out.println("輸入id:");
int id = scanner.nextInt();
System.out.println("輸入name:");
String name = scanner.next();
Emp emp = new Emp(id, name);
hashTable.add(emp);
break;
case "list":
hashTable.list();
break;
case "find":
System.out.println("請輸入要查找的id:");
id = scanner.nextInt();
hashTable.findEmpById(id);
break;
case "exit":
scanner.close();
System.exit(0);
default:
break;
}
}
}
}
添加1,8數據項,這個時候我們就不需要考慮衝突的問題了
總結
hash表是一種根據關鍵字值(key-value)映射到表中的位置而直接訪問的數據結構,構建hash表關鍵在於創建高效的hash函數
需要考慮:
- 計算哈希函數所需時間
- 關鍵字的長度、種類
- 哈希表的大小
- 關鍵字的分佈情況
- 記錄的查找頻率
常見的hash函數有6種:
- 直接尋址法
- 數字分析法
- 平方取中法
- 摺疊法
- 隨機數法
- 取餘法
hash函數可以壓縮數據,將大範圍數據映射到小範圍,但不可避免會出現衝突:不同數據計算出同一下標
當數據出現衝突,會大量消耗時間去計算新的地址,引進了裝填因子的概念,裝填因子=數組中數據項 / 數組容量
,裝填因子表示哈希表中元素填滿的程度,當裝填因子越大,越可能發生衝突
爲了解決衝突,有兩種方法:
- 開放地址法:當地址已被佔有,根據一定規則找到新的位置放置
- 鏈地址法:將數組的項設置爲鏈表或子數組