一、導引
考慮下面這樣一個例子:如果數據中存在重復的元素,請給出輸出第一個重復字符的算法。最簡單最直接的辦法當然是:在給定的字符串中,檢查每個字符是否重復。這種方法的時間復雜度為O(n^2)。
現在給出一個更好的解決方案:我們知道可能的字符集合大小是256(為了簡單期起見,假設只有ASCII字符)。由此創建一個長度為256的數組,并將其所有元素初始化為0。將每個輸入字符放到該數組對應的位置,并增加其計數。由于使用的是數組,所以它僅需要常數時間就可以到達任意指定的位置。當掃描輸入字符時,若得到了一個計數器值已經是1的字符,則可以認為該字符就是第一個重復的字符。
char FirstRepeatedChar(char[] str){
int count[256];
for(int i=0;i<256;++i)
count[i] = 0;
for(int i=0;i<str.length;++i){
if(count[str[i]]==1){
System.out.println(str[i]);
break;
}
}
if(i==len)
System.out.println("No Repeated Characters");
return 0;
}
但是如果我們給定的數組是數字而不是字符,那么關鍵字的取值范圍將為無窮大(字符的個數是256是已知的,但是數字的范圍是未知的)。使用簡單的數組來解決那些關鍵字取值范圍巨大的問題并不是一個正確的選擇。所以要想解決這個問題,則需要以某種方式將所有這些可能的關鍵字映射到可能的內存位置,將關鍵字映射到存儲位置的過程成為散列。
二、散列表的簡介
Java中最底層的數據存儲方式有兩種,一種是數組,另一種就是鏈表。數組查詢速度快,但是增加和刪除元素速度慢;鏈表則正好相反。有沒有一種數據結構能夠綜合一下數組和鏈表,以便發揮它們各自的優勢呢?答案就是散列表!散列表也叫作哈希表,哈希表有較快的查詢速度,以及較快的增刪速度,所以很適合在海量數據的環境中使用。
一般實現哈希表的方法采用“拉鏈法”,我們可以理解為“鏈表的數組”,如下圖:
從上圖我們可以發現哈希表是由數組+鏈表組成的。當我們面臨較少的存儲位置和較多可能的關鍵字時,僅利用簡單數組是沒有足夠的內存空間的。一種解決方案就是使用散列表。散列表是一種數據結構,利用散列函數將關鍵字映射到其關聯的值。
三、散列函數
一個好的散列函數應該具有以下特點:
- 最大限度地減少沖突
- 簡單并快速計算
- 將鍵值在散列表中均勻分布
- 能使用關鍵字提供的所有信息
- 對一組給定關鍵字具有一個高負載因子
其中負載因子=散列表中元素的個數 / 散列表的長度,此參數指出了散列函數是否將關鍵字均勻分布
四、沖突
沖突是指兩個記錄存儲在相同位置的情況。目前解決沖突最常用的是直接鏈接法和開放定址法。
- 直接鏈接法:鏈表數組的應用
??分離鏈接法 - 開放定址法:基于數組實現
??線性探測法(線性搜索)
??二次探測法(非線性搜索)
??雙重散列法(使用兩個散列函數)
(1)分離鏈接法
基于鏈接法的沖突解決方案是將散列表與鏈表形式結合起來實現的。當兩個或多個記錄散列到相同的位置時,這些記錄將構成一個單項鏈表。
(2)開放定址法
這種方法是通過探測來解決沖突的。
1.線性探測法
探測間隔為固定值1。在線性探測中,從發生沖突的原始位置按順序搜索散列表,如果表中的某個位置被占據,則查找下一個位置。必要時,還可以從表的最后一個位置循環到表的第一個位置進行搜索。用于再次散列的函數如下:
rehash(key) = (n+1)%tablesize
線性探測的一個問題是,表項往往在散列表中聚集,集散列表包含一組連續的被占據的位置,這一現象稱為聚集。因此,散列表中的某部分可能相當密集,即使另一部分元素相對較少。因此聚集會導致較長的探測搜索,從而降低整體效率。
2.二次探測法
探測間隔的增加與散列值成正比(因此間隔線性地增加,索引值由一個二次函數描述)。在二次探測中,從發生沖突的初始位置i開始,如果某個位置被占據,則探測i+1^2、i+2 ^2 、i+3^2、i+4 ^2等位置。如果有必要,將從表的最后一個位置循環到表的第一個位置進行探測。再次散列的函數如下:
rehash(key) = (n+k^2)%tablesize
【例子】
表長是11(0..10);散列函數:h(key) = key mod 11
31 mod 11 = 9
19 mod 11 = 8
2 mod 11 = 2
13 mod 11 = 2 → (2+1^2)mod 11 = 3
25 mod 11 = 3 → (3+1^2)mod 11 = 4
24 mod 11 = 2 → (2+1^2)mod 11 , (2+2^2)mod 11 = 6
聚集問題可以使用二次探測方法消除。但是還是存在出現聚集的可能。聚集是由多個關鍵字映射到同一個散列值引起的,所以與這些關鍵字相關的探測序列將隨著重復沖突的出現而被延長。
3.雙重散列法
探測間隔由另一個散列函數計算生成,雙重散列法用一種更好的方式減少了聚集。由于探測序列的增量使用第二個散列函數計算,所以第二個散列函數h2應遵循:
h2(key)≠0且h2≠h1
算法首先探測位置h1(key)。若該位置已被占據,那么繼續探測位置h1(key)+h2(key),h1(key)+2×h2(key)....。
【例子】
表長是11(0..10)
散列函數:假設h1(key) = key mod 11、h2(key) =7- (key mod 7)
插入關鍵字:
58 mod 11 = 3
14 mod 11 = 3 → (3+7) mod 11 =10
91 mod 11 = 3 → (3+7) mod 11 , (3+2×7) mod 11=6
25 mod 11 = 3 → (3+3) mod 11 , (3+2×3) mod 11 = 9
五、哈希的應用
Java的每個類都有hashCode方法,hashCode方法返回該對象的哈希碼值。
那么為什么對象都要有hashCode方法呢?考慮一種情況,當向集合中插入對象時,如何判別在集合中是否已經存在該對象了?也許大多數人都會想到調用equals方法來逐個進行比較,這個方法確實可行。但是如果集合中已經存在一萬條數據或者更多的數據,如果采用equals方法去逐一比較,效率必然是一個問題。此時hashCode方法的作用就體現出來了,當集合要添加新的對象時,先調用這個對象的hashCode方法,得到對應的hashcode值來進行重復的判斷。所以,之所以有hashCode方法,是因為在批量的對象比較中,hashCode要比equals來得快。
兩個對象equals相等那么hashcode是一定相等的;equals不相等hashcode可能相等可以不相等。因為hashCode說白了是地址值經過一系列的復雜運算得到的結果,而Object中的equals方法底層比較的就是地址值,所以equals()相等,hashCode必定相等;equals()不等,在java底層進行哈希運算的時候有一定的幾率出現相等的hashCode,所以hashCode可等可不等。
如果你重寫了equals()方法,那么一定要記得重寫hashCode()方法。重寫的原則就是按照equals( )中比較兩個對象是否一致的條件用到的屬性來重寫hashCode()。
// HASH
@Override
public int hashCode()
{
int hash = 17;
//根據id生成hashCode
if (this.id != null)
hash = hash * 31 + this.id.hashCode();
return(hash);
}
@Override
public boolean equals(Object o)
{
if (this == o)
return(true);
//利用getClass()獲得當前對象的類型
if (o==null || !this.getClass().equals(o.getClass()))
return(false);
C c = (C)o;
//通過id判斷兩個對象是否相等
return(
(this.id == c.id) ||
(this.id != null && this.id.equals(c.id))
);
}