資料結構—雜湊—PART1(雜湊的Java實現)
0. 前言
我是一名資料結構與演算法的初學者,為了鞏固知識點,與更多的IT朋友們交流,我將在CSDN社群釋出資料結構與算法系列的學習總結。根據學習進度,內容大概1-2週一更,歡迎大家對相關知識點進行校正和補充,共同進步,謝謝~
1. 概念
這一章將介紹雜湊的基本知識以及一些核心思想。高亮部分為重要知識點或需要掌握的概念。1.1節介紹什麼是散列表,1.2-1.4節介紹散列表的核心思想:雜湊函式、解決衝突、再雜湊。
1.1 什麼是散列表
散列表是一種以常數平均時間執行增、刪、查的技術,但不支援排序。 散列表其實是包含一些項的具有固定大小的陣列大小為TableSize,如下表。向陣列中新增元素時,將項對映到 [0,TableSize)中的某個數index,然後把這個元素新增到陣列中相應的位置。這就是散列表的基本想法,接下來就要解決如何對映的問題。
角標 | 項 |
---|---|
0 | 張三 18 |
1 | |
2 | 王五 18 |
3 | 趙六 18 |
… | … |
TableSize-1 |
1.2 雜湊函式h(x)
1.1節介紹了雜湊的基本工作原理,在這一節將解決對映的問題,給定一個項,我們可以根據這個項中的某個關鍵字進行對映,雜湊函式將關鍵字轉換成散列表陣列的角標。
- 當關鍵字Key為整數時,令雜湊函式h(x) = Key % TableSize,這種方法最為簡單,但是需要考慮的是,若表的大小是10,而項的關鍵字個位都是0時,所有的項都被對映到0這個位置,就發生了衝突,為了避免衝突,最好的方法是將TableSize設定為質數,這樣衝突就會減少,但還不能完全避免,比如TableSize = 11,而關鍵字是11的倍數。本文將在1.3中介紹解決衝突的方法
- 當關鍵字是字串時,雜湊函式見Code1:
//Code1 關鍵字是字串時的雜湊函式 //一個良好的雜湊函式,根據Horner法則計算 public int hash(String key, int tableSize){ int hasVal = 0; for (int i = 0; i < key.length(); i++) { hasVal = 37 * hasVal + key.charAt(i); } hasVal %= tableSize; if(hasVal < 0)//計算出來的hasVal可能會溢位,產生負數,因此需要進行判斷
- 使用物件進行雜湊:雜湊函式並不是一成不變的,只是有些雜湊函式能把關鍵字均勻的分配,不易造成衝突,而有些雜湊函式映射出來的結果很差。其實Object的hashCode()方法可以為我們獲取物件的雜湊值,我們可以在此基礎上寫雜湊函式,需要時,也可以覆蓋hashCode()方法。
//基於物件雜湊值的雜湊函式 public int hash(Object obj, int tableSize){ int hasVal = obj.hashCode(); hasVal %= tableSize; if(hasVal < 0) hasVal += tableSize; return hasVal; }
1.2 解決衝突
本節解釋發生衝突時如何解決衝突,但本節並不能解決全部的衝突,這個小問題留在1.3中解決。 將一個新的項存入散列表的某個位置時,這個位置可能已經被其他項佔據,這稱之為衝突。解決衝突的方法有幾種,最簡單的是分離連結法和開放定址法,而開放定址法又分為線性探測、平方探測以及雙雜湊。根據方法的不同,我們有不同的雜湊實現,見第2章。以下介紹這些方法的基本原理和基本概念。
- 分離連結法-分離散列表 將雜湊到同一個值的所有元素儲存到一個表(LinkedList)中,這樣便解決了衝突。散列表陣列儲存的元素為LinkedList連結串列,而每個linkedList儲存衝突的項。比如向連結串列中插入2時發現3佔據了這個位置,但是沒關係,用連結串列把3和2串起來一起放到這個位置即可。將雜湊到同一個值的所有元素儲存到一個表(LinkedList)中,這樣便解決了衝突。散列表陣列儲存的元素為LinkedList連結串列,而每個linkedList儲存衝突的項。比如向連結串列中插入2時發現3佔據了這個位置,但是沒關係,用連結串列把3和2串起來一起放到這個位置即可。 執行查詢時,使用雜湊函式來確定需要遍歷哪個連結串列;執行插入時,檢查對應連結串列中是否存在這個元素,若不存在,則在連結串列的前端插入,這麼做的原因是:新插入的元素最有可能不久後又被訪問。
角標 | 陣列儲存的連結串列 |
---|---|
0 | linkedList0 2->3->4->1 |
1 | linkedList1 5->6->7->8 |
2 | linkedList2 12->10->11->9 |
3 | linkedList3 13->14->16->15 |
… | … |
TableSize-1 | linkedList… …->…->…->… |
- 開放定址法-探測散列表 當儲存元素髮生衝突時,這種方法嘗試另外的單元h(x),直到找出空單元位置。根據1.2節的雜湊函式hash(x)構造新的雜湊函式雜湊函式hi(x) hi(x) = ( hash( x ) + f( i ) ) % tableSize 通過試探h0(x)、h1(x)、h2(x)……這些單元,找到空位。hash(x)是1.2節的雜湊函式,f(i)函式的作用是解決衝突,hi(x)是新的雜湊函式。 根據f(i)的不同,開放定址法可分為線性探測法、平方探測法、雙雜湊法。 總之,開放地址法的思想是,當發生衝突時,我們從衝突發生的位置按照一定的規則不斷試探其他單元,直到找到空單元時把元素存入。但這樣的努力並不能解決所有衝突,有時可能永遠也找不到空位,這樣的探測可能是失效的,對於這個問題,1.3給出解決辦法。
1.3 再雜湊
考慮一種情況,當表的大小固定而儲存的元素數不斷增多時,對於分散連結法,連結串列長度將不斷增加,導致查詢和刪除時執行時間的增加(因為要遍歷連結串列);而對於開放定址法,元素不斷增多時,可能導致元素找不到空位,從而引發死迴圈,造成致命錯誤。再雜湊指當元素數目過多時,增大散列表TableSize的大小。 因此,分離連結法通過在雜湊降低刪除和查詢操作的複雜度;開放定址法通過再雜湊降低運算複雜度,並解決元素找不到空位這種致命的錯誤,這回應了1.2節中遺留的問題。 那麼元素過多怎麼定義,具體什麼時候需要進行再雜湊呢? 定義散列表的裝填因子R為散列表中元素個數與該表大小的比,對於分離連線法,一般使得R = 1,如果 R > 1 則進行再雜湊;對於開放地址法,R > 0.5 的時候進行再雜湊。 相關定理表明,如果使用平方探測,且表大小是質數時,那麼當表至少有一半是空的時候,總能插入一個新的元素。這解決了開放地址法中平方探測法的致命錯誤。為了減少衝突以及解決致命錯誤,我們要把散列表的大小設定為質數。
2. 雜湊的實現(Java程式碼)
本章使用java語言實現分離連結法以及開放定址中的平方探測法,線性探測法以及雙雜湊僅做簡單介紹。
2.1 分離連結法散列表的Java實現
import java.util.LinkedList;
import java.util.List;
/**
* 分離連結法實現散列表
* 表中維護著一個數組array、元素個數currentSize
* 定義了散列表預設大小
*/
public class MySeparateChainingHashTable<T> {
/**
* 預設構造方法
*/
public MySeparateChainingHashTable(){this(DEFAULT_TABLE_SIZE); }
/**
* 建立一個具有指定大小的散列表
* @param tableSize 指定散列表的大小
*/
public MySeparateChainingHashTable(int tableSize){
array = new LinkedList[nextPrime(tableSize)];
for (int i = 0; i < array.length; i++) {
array[i] = new LinkedList<T>();
}
currentSize = 0;
}
/**
* 將表置空
*/
public void makeEmpty(){
for(List list : array){
list.clear();
}
currentSize = 0;
}
/**
* 儲存一個元素,若元素存在,則無需儲存
* 若元素不存再則儲存該元素
* 若裝填因子>1則進行再雜湊
* @param t 要儲存的元素
*/
public void insert(T t){
List<T> list = array[myhash(t)]; //找到對應的連結串列
if(!list.contains(t)){
list.add(t);
//若裝填因子大於1,則進行再雜湊操作
if(++currentSize > array.length)
rehash();
}
}
/**
* 判斷散列表中是否存在元素t
* @param t 要查詢的元素
* @return 是否存在該元素
*/
public boolean contains(T t){
List<T> list = array[myhash(t)];
return list.contains(t);
}
/**
* 從散列表中刪除元素t
* @param t 想要刪除的元素
*/
public void remove(T t){
if(contains(t)){
List<T> list = array[myhash(t)];
list.remove(t);
currentSize--;
}
}
/**
* 判斷散列表是否為空
* @return
*/
public boolean isEmpty(){
return size() == 0;
}
/**
* 獲取散列表大小
* @return
*/
public int size(){
return currentSize;
}
/*
再雜湊,防止裝填因子過大而增加運算複雜度
*/
private void rehash() {
List<T>[] oldArray = array;
array = new LinkedList[nextPrime(oldArray.length * 2)];
for (int i = 0; i < array.length; i++) {
array[i] = new LinkedList<T>();
}
currentSize =0;
for (List<T> list : oldArray){
for(T e : list){
insert(e);
}
}
}
/*
雜湊函式,獲取元素t對應散列表的陣列角標
*/
private int myhash(T t) {
int hashVal = t.hashCode();
hashVal %= array.length;
if (hashVal < 0)
hashVal += array.length;
return hashVal;
}
/*
獲取不小於tableSize的最小質數
*/
private int nextPrime(int tableSize) {
if(isPrime(tableSize)) return tableSize;
while(isPrime(tableSize))
tableSize++;
return tableSize;
}
/*
判斷一個數是否為質數
*/
private boolean isPrime(int num) {
if(num < 2) return false;
for (int i = 2; i < num; i++) {
if (num % i == 0) return false;
}
return true;
}
//常量
private static final int DEFAULT_TABLE_SIZE = 11;
//維護的欄位
private List<T>[] array;
private int currentSize;
}
下面對這個散列表進行一個簡單的載入測試:
public static void main(String[] args){
MySeparateChainingHashTable t = new MySeparateChainingHashTable<String>();
t.insert("aaa");
t.insert("bbb");
t.insert("ccc");
System.out.println(t.contains("aaa")); //true
System.out.println(t.contains("ddd")); //false
t.remove("aaa");
System.out.println(t.contains("aaa")); //false
t.insert("ddd");
System.out.println(t.contains("ddd")); //true
System.out.println(t.size()); //3
System.out.println(t.isEmpty()); //false
t.makeEmpty();
System.out.println(t.isEmpty()); //true
System.out.println(t.contains("ddd")); //false
//載入測試
for(int i =0; i < 1000; i++){
t.insert("元素"+i);
}
System.out.println(t.contains("元素999")); //true
System.out.println(t.size()); //1000
t.makeEmpty();
System.out.println(t.contains("元素999")); //false
}
測試成功,證明散列表是可用的。
2.2 線性探測法簡介
線性探測法是開放地址法的一種,只是規定其解決衝突的函式 f(i) = i。線性探測法容易造成表中元素的聚集,導致元素無法均勻的存入表中,增加了運算的複雜度(需要很多次探測才能解決衝突)。因此不推薦這種方法。
2.3 平方探測法散列表的Java實現
平方探測法解決了線性探測法的聚焦問題。平方探測法的f(i) = i2。值得強調的是,開放地址法的刪除操作是懶惰刪除。如果我們真的刪除了某個元素,那麼對跳過這個元素的其他元素執行contians()方法時將捕獲到null,contains方法失效。我們寫一個內部類HashEntry來儲存元素資訊。
/**
* 開放地址法——平方探測實現散列表
* 維護一個HashEntry<T>[]陣列
* 維護currentSize:當前元素個數
* 定義了散列表的預設大小DEFAULT_TABLE_SIZE
* @param <T>
*/
public class MyQuadraticProbingHashTable<T> {
/**
* 預設構造初始化,建立預設大小(11)的散列表
*/
public MyQuadraticProbingHashTable(){
this(DEFAULT_TABLE_SIZE);
}
/**
* 建立指定大小的散列表
* @param tableSize 接收一個散列表的大小
*/
public MyQuadraticProbingHashTable(int tableSize){
allocate(tableSize);
}
/**
* 把元素插入到散列表中,若元素已存在,則什麼也不做
* 若裝填因子過大則執行再雜湊
* @param t 接收一個元素
*/
public void insert(T t){
int currentPost = findPos(t);
if (!isActive(currentPost)){
array[currentPost] = new HashEntry<T>(t,true);
currentSize++;
if (currentSize > array.length / 2)
rehash();
}
}
/**
* 判斷散列表中是否包含元素t
* @return
*/
public boolean contains(T t){
return isActive(findPos(t));
}
/**
* 將元素t從散列表中刪除
* @param t
*/
public void remove(T t){
if (contains(t)){
array[findPos(t)].isActive = false;
currentSize--;
}
}
/**
* 獲得散列表中元素個數
* @return
*/
public int size(){
return currentSize;
}
/**
* 判斷散列表是否為空
* @return
*/
public boolean isEmpty(){
return size() ==0;
}
/**
* 將散列表置空
*/
public void makeEmpty(){
allocate(array.length);
currentSize = 0;
}
/*
當裝填因子 > 0.5時進行再雜湊,避免運算複雜度過大,避免出現致命的錯誤。
*/
private void rehash() {
HashEntry<T>[] oldArray = array;
allocate(nextPrime(2 * oldArray.length));
currentSize = 0;
for(HashEntry<T> entry : oldArray){
if (entry != null && entry.isActive)
insert(entry.eletment);
}
}
/*
判斷指定位置是否存在active的元素
*/
private boolean isActive(int currentPost){
return (array[currentPost] != null && array[currentPost].isActive);
}
/*
返回元素t所在的位置,若散列表中沒有元素t,則返回t可以新增的位置
這個函式其實就是再平方法的雜湊函式
*/
private int findPos(T t) {
int currentPos = myhash(t);
int offset = 1;
while (array[currentPos] != null &&
!array[currentPos].eletment.equals(t)){
currentPos += offset;
offset +=2;
if(currentPos >= array.length)
currentPos -= array.length;
}
return currentPos;
}
/*
雜湊函式,獲取元素t對應散列表的陣列角標
*/
private int myhash(T t) {
int hashVal = t.hashCode();
hashVal %= array.length;
if (hashVal < 0)
hashVal += array.length;
return hashVal;
}
/*
為陣列HashEntry[]初始化
*/
private void allocate(int size) {
array = new HashEntry[nextPrime(size)];
}
/*
獲取不小於tableSize的最小質數
*/
private int nextPrime(int tableSize) {
if(isPrime(tableSize)) return tableSize;
while(isPrime(tableSize))
tableSize++;
return tableSize;
}
/*
判斷一個數是否為質數
*/
private boolean isPrime(int num) {
if(num < 2) return false;
for (int i = 2; i < num; i++) {
if (num % i == 0) return false;
}
return true;
}
/*
私有類,儲存元素以及元素的狀態,便於惰性刪除
*/
private static class HashEntry<T>{
public T eletment;
public boolean isActive;
public HashEntry(T t){
this(t, true);
}
public HashEntry(T t, boolean isActive){
eletment = t;
this.isActive = isActive;
}
}
private static final int DEFAULT_TABLE_SIZE = 11;
//維護的私有變數
private HashEntry<T>[] array;
private int currentSize;
}
下面對這個散列表進行一個簡單的載入測試:
public static void main(String[] args){
MyQuadraticProbingHashTable<String> t = new MyQuadraticProbingHashTable<String>();
t.insert("aaa");
t.insert("bbb");
t.insert("ccc");
System.out.println(t.contains("aaa")); //true
System.out.println(t.contains("ddd")); //false
t.remove("aaa");
System.out.println(t.contains("aaa")); //false
t.insert("ddd");
System.out.println(t.contains("ddd")); //true
System.out.println(t.size()); /