上篇文章的查找是不是有意猶未盡的感覺呢?因爲我們是真真正正地接觸到了時間複雜度的優化。從線性查找的 O(n) 直接優化到了折半查找的 O(logN) ,絕對是一個質的飛躍。但是,我們的折半查找最核心的一個要求是什麼呢?那就是必須是原始數據是要有序的。這可是個麻煩事啊,畢竟如果數據量很龐大的話,排序又會變得很麻煩。不過彆着急,今天我們要學習的散列表查找又是另一種形式的查找,它能做到什麼程度呢?
O(1) ,是的,你沒看錯,散列表查找在最佳情況下是可以達到這種常數級別的查找效率的,是不是很神奇。
哈希散列(除留餘數法)
先通過實際的例子看一種非常簡單的散列算法。在數據量比較大的情況下,我們往往要對數據表進行表操作,最簡單的一種方案就是根據某一個字段,比如說 ID 來對它進行取模。也就是說,假如我們要分20張表,那麼就用數據的 ID 來除以 20 ,然後獲得它的餘數。然後將這條數據添加到餘數所對應的這張表中。我們通過代碼來模擬這個操作。
or($i=0;$i<100;$i++){
$arr[] = $i+1;
}
$hashKey = 7;
$hashTable = [];
for($i=0;$i<100;$i++){
$hashTable[$arr[$i]%$hashKey][] = $arr[$i];
}
print_r($hashTable);
在這裏,我們假設是將 100 條數據放到 7 張表中,就是直接使用取模運算符 % 來獲取餘數就行了,接着就將數據放入到對應的數組下標中。這 100 個數據就被分別放置在了數組中 0-6 的下標中。這樣,我們就實現了最簡單的一種數據分表的思想。當然,在實際的業務開發中要遠比這個複雜。因爲我們考慮各種不同的場景來確定到底是以什麼形式進行分表,分多少張表,以及後續的擴展情況,也就是說,真實情況下要比我們這裏寫的這個複雜很多。
做爲演示代碼來說,這種分表的散列形式其實就是散列表查找中最經典也是使用最多的除留餘數法。其實還有其它的一些方法,比如平方取中法、摺疊法、數字分析法之類的方法。它們的核心思想都是作爲一個散列的哈希算法,讓原始數據對應到一個新的值(位置)上。
類似的思想其實最典型的就是 md5() 的散列運算,不同的內容都會產生不同的值。另外就是 Redis 、 Memcached 這類的鍵值對緩存數據庫,它們其實也會將我們設置的 Key 值進行哈希後保存在內存中以實現快速的查找能力。
散列衝突問題(線性探測法)
上面的例子其實我們會發現一個問題,那就是哈希算法的這個值如果很小的話,就會有很多的重複衝突的數據。如果是真實的一個存儲數據的散列表,這樣的存儲其實並不能幫我們快速準確的找到所需要的數據。查找查找,它核心的能力其實還是在查找上。那麼如果我們隨機給定一些數據,然後在同樣長度的範圍內如何保存它們並且避免衝突呢?這就是我們接下來要學習的散列衝突要解決的問題。
$arr = [];
$hashTable = [];
for($i=0;$i<$hashKey;$i++){
$r = rand(1,20);
if(!in_array($r, $arr)){
$arr[] = $r;
}else{
$i--;
}
}
print_r($arr);
for($i=0;$i<$hashKey;$i++){
if(!$hashTable[$arr[$i]%$hashKey]){
$hashTable[$arr[$i]%$hashKey] = $arr[$i];
}else{
$c = 0;
echo '衝突位置:', $arr[$i]%$hashKey, ',值:',$arr[$i], PHP_EOL;
$j=$arr[$i]%$hashKey+1;
while(1){
if($j>=$hashKey){
$j = 0;
}
if(!$hashTable[$j]){
$hashTable[$j] = $arr[$i];
break;
}
$c++;
$j++;
if($c >= $hashKey){
break;
}
}
}
}
print_r($hashTable);
這回我們只生成 7 個隨機數據,讓他們依然以 7 爲模進行除留取餘。同時,我們還需要將它們以哈希後的結果保存到另一個數組中,可以將這個新的數組看做是內存中的空間。如果有哈希相同的數據,那當然就不能放在同一個空間了,要不同一個空間中有兩條數據我們就不知道真正要取的是哪個數據了。
在這段代碼中,我們使用的是開放地址法中的線性探測法。這是最簡單的一種處理哈希衝突的方式。我們先看一下輸出的結果,然後再分析衝突的時候都做了什麼。
// Array
// (
// [0] => 17 // 3
// [1] => 13 // 6
// [2] => 9 // 2
// [3] => 19 // 5
// [4] => 2 // 2 -> 3 -> 4
// [5] => 20 // 6 -> 0
// [6] => 12 // 5 -> 6 -> 0 -> 1
// )
// 衝突位置:2,值:2
// 衝突位置:6,值:20
// 衝突位置:5,值:12
// Array
// (
// [3] => 17
// [6] => 13
// [2] => 9
// [5] => 19
// [4] => 2
// [0] => 20
// [1] => 12
// )
首先,我們生成的數字是 17、13、9、19、2、20、12 這七個數字。
17%7=3,17 保存到下標 3 中。
13%7=6,13 保存到下標 6 中。
9%7=2,9 保存到下標 2 中。
19%7=5,19 保存到下標 5 中。
2%7=2,好了,衝突出現了,2%7 的結果也是 2 ,但是 2 的下標已經有人了,這時我們就從 2 開始往後再看 3 的下標有沒有人,同樣 3 也被佔了,於是到 4 ,這時 4 是空的,就把 2 保存到了下標 4 中。
20%7=6,和上面一樣,6 已經被佔了,於是我們回到開始的 0 下標,發現 0 還沒有被佔,於是 20 保存到下標 0 中。
最後的 12%7=5,它將依次經過下標 5 、6 、0、1 最後放在下標 1 處。
最後生成的結果就是我們最後數組輸出的結果。可以看出,線性探測其實就是如果發現位置被人佔了,就一個一個的向下查找。所以它的時間複雜度其實並不是太好,當然,最佳情況是數據的總長度和哈希鍵值的長度相吻合,這樣就能達到 O(1) 級別了。
當然,除了線性探測之外,還有二次探測(平方)、僞隨機探測等算法。另外也可以使用鏈表來實現鏈地址法來解決哈希衝突的問題。這些內容大家可以自己查閱一下相關的文檔或書籍。
總結
哈希散列最後的查找功能其實就和我們上面生成那個哈希表的過程一樣,發現有衝突的解決方式也是一樣的,這裏就不多說了。對於哈希這塊來說,不管是教材還是各類學習資料,其實介紹的內容都並不是特別的多,所以,我們也是以入門的心態來簡單地瞭解一下哈希散列這塊的知識,更多的內容大家可以自己研究多多分享哈!
測試代碼:
https://github.com/zhangyue0503/Data-structure-and-algorithm/blob/master/6.查找/source/6.2散列表查找.php
參考文檔:
《數據結構》第二版,嚴蔚敏
《數據結構》第二版,陳越