前言
哈希(Hash)或者說散列表,它是一種基礎(chǔ)數(shù)據(jù)結(jié)構(gòu)。Hash 表是一種特殊的數(shù)據(jù)結(jié)構(gòu),它同數(shù)組、鏈表以及二叉排序樹等相比較有很明顯的區(qū)別,但它又是是數(shù)組和鏈表的基礎(chǔ)上演化而來,既具有數(shù)組的有點(diǎn),又具有鏈表的有點(diǎn)。能夠快速定位到想要查找的記錄,而不是與表中存在的記錄的關(guān)鍵字進(jìn)行比較來進(jìn)行查找。應(yīng)用了函數(shù)映射的思想將記錄的存儲位置與記錄的關(guān)鍵字關(guān)聯(lián)起來,從而能夠很快速地進(jìn)行查找。
一、Hash設(shè)計(jì)思想
試想如果我們對一個(gè)數(shù)組進(jìn)行查詢,這個(gè)數(shù)組里,每一個(gè)元素都是一個(gè)字符串。我們知道數(shù)組最快的檢索辦法是通過數(shù)組的下標(biāo)進(jìn)行檢索,但是對于這種場景,我們無能為力,只能從頭查到尾,從而查詢出目標(biāo)元素。
1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|
zhangsan | lisi | wanger | wangwu | zhangsi | gaofei |
如果我們要根據(jù)名字找到其中的任何一個(gè)元素,就需要遍歷整個(gè)數(shù)組。最壞情況下時(shí)間復(fù)雜度是O(n) ,但是借助 Hash 可以將時(shí)間復(fù)雜度降為O(1)。
Hash表采用一個(gè)映射函數(shù) f :key —> address 將關(guān)鍵字映射到該記錄在表中的存儲位置,從而在想要查找該記錄時(shí),可以直接根據(jù)關(guān)鍵字和映射關(guān)系計(jì)算出該記錄在表中的存儲位置,通常情況下,這種映射關(guān)系稱作為Hash函數(shù),而通過Hash函數(shù)和關(guān)鍵字計(jì)算出來的存儲位置(注意這里的存儲位置只是表中的存儲位置,并不是實(shí)際的物理地址)稱作為Hash地址。比如上述例子中,假如聯(lián)系人信息采用Hash表存儲,則當(dāng)想要找到 “l(fā)isi” 的信息時(shí),直接根據(jù) “l(fā)isi” 和 Hash 函數(shù)計(jì)算出 Hash 地址即可。
哈希算法歷史悠久,業(yè)界著名的哈希算法也有很多,比如 MD5、SHA。哈希算法是指將任意長度的二進(jìn)制值串映射為固定長度的二進(jìn)制值串,這個(gè)映射的規(guī)則就是哈希算法,而通過原始數(shù)據(jù)映射之后得到的二進(jìn)制值串就是哈希值。有以下幾個(gè)特點(diǎn):
- 從哈希值不能反向推導(dǎo)出原始數(shù)據(jù)(所以哈希算法也叫單向哈希算法或單向散列函數(shù))。
- 對輸入數(shù)據(jù)非常敏感,哪怕原始數(shù)據(jù)只修改了一個(gè) Bit,最后得到的哈希值也大不相同。
- 散列沖突的概率要很小,對于不同的原始數(shù)據(jù),哈希值相同的概率非常小。
- 哈希算法的執(zhí)行效率要盡量高效,針對較長的文本,也能快速地計(jì)算出哈希值。
為了更好說明這種設(shè)計(jì)思想,筆者先設(shè)計(jì)出一種最笨的 Hash 函數(shù),將所有字符串中的字符轉(zhuǎn)化為數(shù)字后相加。
858 | 433 | 644 | 665 | 756 | 619 |
---|---|---|---|---|---|
zhangsan | lisi | wanger | wangwu | zhangsi | gaofei |
上表中數(shù)組的下標(biāo)就是字符串對應(yīng)的數(shù)字值。根據(jù)對應(yīng)的數(shù)字值,我們就能輕易找到任何想要的對象,時(shí)間復(fù)雜度為O(1)。
二、Hash函數(shù)設(shè)計(jì)
所謂的 hash 算法就是將字符串轉(zhuǎn)換為數(shù)字的算法。通常有以下幾種構(gòu)造 Hash 函數(shù)的方法:
2.1 直接定址法
取關(guān)鍵字或者關(guān)鍵字的某個(gè)線性函數(shù)為 Hash 地址,即address(key) = a * key + b
; 如知道學(xué)生的學(xué)號從2000開始,最大為4000,則可以將address(key)=key-2000
(其中a = 1)作為Hash地址。
2.2 平方取中法
對關(guān)鍵字進(jìn)行平方計(jì)算,取結(jié)果的中間幾位作為 Hash 地址。如有以下關(guān)鍵字序列 {421,423,436} ,平方之后的結(jié)果為 {177241,178929,190096} ,那么可以取中間的兩位數(shù) {72,89,00} 作為 Hash 地址。
2.3 折疊法
將關(guān)鍵字拆分成幾部分,然后將這幾部分組合在一起,以特定的方式進(jìn)行轉(zhuǎn)化形成Hash地址。如圖書的 ISBN 號為 8903-241-23,可以將 address(key)=89+03+24+12+3 作為 Hash 地址。
2.4 除留取余法
如果知道 Hash 表的最大長度為 m,可以取不大于m的最大質(zhì)數(shù) p,然后對關(guān)鍵字進(jìn)行取余運(yùn)算,address(key)=key % p
。這里 p
的選取非常關(guān)鍵,p
選擇的好的話,能夠最大程度地減少?zèng)_突,p
一般取不大于m的最大質(zhì)數(shù)。
三、Hash表大小的確定
Hash 表的空間如果遠(yuǎn)遠(yuǎn)大于實(shí)際存儲的記錄數(shù)據(jù)的個(gè)數(shù),則造成空間浪費(fèi);如果過小,則容易造成沖突。Hash 表大小確定通常有這兩種思路:
- 如果最初知道存儲的數(shù)據(jù)量,則需要根據(jù)存儲個(gè)數(shù) 和 關(guān)鍵字的分布特點(diǎn)來確定 Hash 表的大小。
- 事先不知道最終需要存儲的記錄個(gè)數(shù),需要?jiǎng)討B(tài)維護(hù)Hash表的容量,此時(shí)可能需要重新計(jì)算 Hash 地址。
四、Hash 沖突及解決方案
4.1 Hash沖突產(chǎn)生
有這樣一個(gè)問題:因?yàn)槲覀兪怯脭?shù)組大小對哈希值進(jìn)行取模,有可能不同鍵值所得到的索引值相同,這里就是沖突。如在最初的實(shí)例中,如果多出了sizhang
這樣一個(gè)元素,那么就存在兩個(gè) 756。
858 | 433 | 644 | 665 | 756 | 619 | 756 |
---|---|---|---|---|---|---|
zhangsan | lisi | wanger | wangwu | zhangsi | gaofei | sizhang |
顯然出現(xiàn)的這種情況是不合理的,解決該沖突的方法就是改變數(shù)據(jù)結(jié)構(gòu)。我們將數(shù)組內(nèi)的元素改變?yōu)橐粋€(gè)鏈表,這樣就能容下足夠多的元素了,沖突問題也能得到解決。具體如何解決請看下面的鏈地址法
。
4.2 Hash 沖突解決
4.2.1 開放定址法
發(fā)生沖突時(shí),使用某種探測技術(shù)在 Hash 表中形成一個(gè)探測序列,然后沿著這個(gè)探測序列依次查找下去,當(dāng)碰到一個(gè)空的單元時(shí),則插入其中。比較常用的探測方法有線性探測法
,如有一組關(guān)鍵字{12,13,25,23,38,34,6,84,91},Hash 表長為14,Hash 函數(shù)為 address(key) = key % 11
,當(dāng)插入12,13,25時(shí)可以直接插入,而當(dāng)插入 23 時(shí),地址 1 被占用了(因?yàn)?12%11 和 23%11 的結(jié)果相同)。此時(shí)沿著地址 1 依次往下探測(探測步長可以根據(jù)情況而定),直到探測到地址4,發(fā)現(xiàn)為空,則將 23 插入其中。
4.2.2 鏈地址法
采用數(shù)組和鏈表相結(jié)合的數(shù)據(jù)結(jié)構(gòu),將 Hash 地址相同的記錄存儲在一張線性表中,而每張表的表頭的序號即為計(jì)算得到的Hash地址。如下圖最左邊是數(shù)組結(jié)構(gòu),數(shù)組內(nèi)的元素為鏈表結(jié)構(gòu)。
所以針對之前案列沖突的解決方案如下:
檢索的時(shí)候可以這樣檢索,首先找到gaofei后,之后再遍歷鏈表,找到feigao了。同理對于 sizhang 的沖突也是如此解決。
五、Hash 表的用處以及優(yōu)劣
5.1 Hash 表的實(shí)際應(yīng)用
上述說了這么多關(guān)于 Hash 表的知識點(diǎn),但是 Hash 表在代碼的世界中,實(shí)際上又有什么應(yīng)用場景,可能有些讀者會(huì)一頭霧水,這里筆者就以簡單的三個(gè)例子來說明 Hash 表的實(shí)際應(yīng)用場景。
- 1 、找出兩文件找出重復(fù)的元素
假設(shè)有兩個(gè)文件,文件中均包含一些短字符串,字符串個(gè)數(shù)分別為n。它們是有重復(fù)的字符串,現(xiàn)在需要找出所有重復(fù)的字符串。
最笨的解決辦法可能是:遍歷文件 1 中的每個(gè)元素,取出每一個(gè)元素分別去文件 2 中進(jìn)行查找,這樣的時(shí)間復(fù)雜度為O(n^2)。
但是借助 Hash 表可以有一種相對巧妙的方法,分別遍歷文件 1 中的元素和文件 2 中的元素,然后放入 Hash Table 中,對于遍歷的每一個(gè)元素我們只要簡單的做一下計(jì)數(shù)處理即可。最后遍歷整個(gè) Hash 列表,找出所有個(gè)數(shù)大于 1 的元素即為重復(fù)的元素。
- 2、找出兩文件找出出現(xiàn)次數(shù)最多的元素
同找出兩文件找出重復(fù)的元素
這樣的問題解決方案類似,只是在最后遍歷的時(shí)找計(jì)數(shù)最大的元素,即為出現(xiàn)次數(shù)最多的元素。
- 3、路由算法
多線程處理數(shù)據(jù)的場景下,通常需要將一個(gè)數(shù)據(jù)集分給不同的線程進(jìn)行處理,同時(shí)要保證,相同的元素需要分到相同的處理線程上。這
其實(shí)這個(gè)就是一個(gè)很典型的 Hash 值應(yīng)用場景,對于很多的計(jì)算引擎默認(rèn)都是用 Hash 算法去解決這個(gè)問題。因?yàn)橄嗤氐?Hash 值相同,那么我們可以取 Hash 之后進(jìn)行模運(yùn)算,運(yùn)算結(jié)果分配到不同的線程。
5.2 Hash 表的優(yōu)缺點(diǎn)及注意點(diǎn)
-
優(yōu)點(diǎn)
哈希表的效率非常高,查找、插入、刪除操作只需要接近常量的時(shí)間即0(1)的時(shí)間級。如果需要在一秒種內(nèi)查找上千條記錄通常使用哈希表,哈希表的速度明顯比樹快,樹的操作通常需要O(N)的時(shí)間級。哈希表不僅速度快,編程實(shí)現(xiàn)也相對容易。如果不需要遍歷數(shù)據(jù),不二的選擇。 -
缺點(diǎn)
它是基于數(shù)組的,數(shù)組創(chuàng)建后難于擴(kuò)展。有些情況下,哈希表被基本填滿時(shí),性能下降得非常嚴(yán)重,所以開發(fā)者必須要清楚表中將要存儲的數(shù)據(jù)量?;蛘咭部梢远ㄆ诘匕褦?shù)據(jù)轉(zhuǎn)移到更大的哈希表中,不過這個(gè)過程耗時(shí)相對比較大。 -
注意點(diǎn)
在設(shè)計(jì)Hash算法的時(shí)候。一定要保證相同字符串產(chǎn)生的 Hash 值相同,同時(shí)要盡量的減小Hash沖突的發(fā)生,這樣才算是好的 hash 算法。
六、Hash 在 iOS 中的應(yīng)用
這一部分的篇幅可能稍稍有點(diǎn)大,筆者原本打算給這一部分抽出來單獨(dú)寫一篇文章,但是發(fā)現(xiàn)沒有 Hash 概念做鋪墊,文章略顯空洞,所以這里干脆把所有東西整合到一起,請讀者耐下心來看。
這一部分的內(nèi)容就以下面的一個(gè)問題為中心。并在此問題上不斷的擴(kuò)充,以點(diǎn)帶面。
iOS系統(tǒng)API給我們提供一個(gè)自動(dòng)過濾重復(fù)元素的容器 NSMutableSet/NSSet,如:當(dāng)我們向該實(shí)例對象中添加字符串時(shí),如果重復(fù)添加兩個(gè)相同的字符串,集合中只會(huì)保留一個(gè)。NSMutableSet/NSSet內(nèi)部一些實(shí)現(xiàn)機(jī)制要比我們自己寫的濾重方法效率高。但是對于自定義一個(gè)類如Person,如果想利用NSMutableSet/NSSet來過濾重復(fù)元素(如多個(gè)Person實(shí)例的uid相同),我們必須要同時(shí)實(shí)現(xiàn)
- (BOOL)isEqual:
和- (NSUInteger)hash
這兩個(gè)方法。這里先簡單介紹他們的關(guān)系:兩個(gè)相等的實(shí)例,他們的hash值一定相等。但是hash值相等的兩個(gè)實(shí)例,不一定相等
。重點(diǎn)來了,利用 NSMutableSet/NSSet 具體如何實(shí)現(xiàn)過濾 Person 重復(fù)元素 ?
在解決這個(gè)問題之前我先用簡單的篇幅 6.1 小結(jié) 和 6.2 小結(jié) 分別介紹- (BOOL)isEqual:
和- (NSUInteger)hash
這兩個(gè)方法。具體可以參考這篇文章。
6.1 關(guān)于- (BOOL)isEqual:
方法
為什么要有
isEqual
方法?
OC 中 == 運(yùn)算符只是簡單地判斷是否是同一個(gè)對象, 而 isEqual 方法可以判斷對象是否相同。如何重寫
isEqual
方法?
但對于自定義類型來說, 做判等時(shí)通常需要重寫isEqual方法。
@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSDate *birthday;
@end
- (BOOL)isEqual:(id)object {
if (self == object) {
return YES;
}
if (![object isKindOfClass:[Person class]]) {
return NO;
}
return [self isEqualToPerson:(Person *)object];
}
- (BOOL)isEqualToPerson:(Person *)person {
if (!person) {
return NO;
}
BOOL haveEqualNames = (!self.name && !person.name) || [self.name isEqualToString:person.name];
BOOL haveEqualBirthdays = (!self.birthday && !person.birthday) || [self.birthday isEqualToDate:person.birthday];
return haveEqualNames && haveEqualBirthdays;
}
上述代碼主要步驟如下:
1、 ==運(yùn)算符判斷是否是同一對象, 因?yàn)橥粚ο蟊厝煌耆嗤?br>
2、 判斷是否是同一類型, 這樣不僅可以提高判等的效率, 還可以避免隱式類型轉(zhuǎn)換帶來的潛在風(fēng)險(xiǎn)
3、通過封裝的isEqualToPerson方法, 提高代碼復(fù)用性
4、 判斷person是否是nil, 做參數(shù)有效性檢查
5、 對各個(gè)屬性分別使用默認(rèn)判等方法進(jìn)行判斷
6、 返回所有屬性判等的與結(jié)果
6.2 關(guān)于- (NSUInteger)hash
方法
hash方法什么時(shí)候被調(diào)用?
如果在 Person 類中重寫- (NSUInteger)hash
方法,該方法只在 Person 實(shí)例對象被添加至NSSet或?qū)erson實(shí)例對象設(shè)置為NSDictionary的 key 時(shí)會(huì)調(diào)用。注意是設(shè)置為 key 而不是 valuehash方法和判等的關(guān)系?
為了優(yōu)化判等的效率, 基于 hash 的 NSSet 和 NSDictionary 在判斷成員是否相等時(shí), 通常會(huì)這樣做:
首先判斷 hash 值是否和目標(biāo) hash 值相等。如果相同再進(jìn)行對象之后的判等邏輯, 作為判等的結(jié)果; 如果不等, 直接判斷為不相等。
簡單地說:hash值是對象判等的必要非充分條件。
如何重寫 hash 方法?
很多人在iOS開發(fā)中, 都是這么重寫hash方法的,如果自己親自測試一下會(huì)發(fā)現(xiàn)直接重寫父類方法并不能實(shí)現(xiàn)過濾重復(fù)元素的功能。
- (NSUInteger)hash {
return [super hash];
}
對于上面的 Person 類正確的 Hash 實(shí)現(xiàn)方法應(yīng)該是借助位運(yùn)算。代碼如下:
- (NSUInteger)hash {
return [self.name hash] ^ [self.birthday hash];
}
6.3 同時(shí)實(shí)現(xiàn)- (BOOL)isEqual:
和 - (NSUInteger)hash
方法,實(shí)現(xiàn)過濾自定義實(shí)例的功能
6.3.1 代碼實(shí)現(xiàn)
@interface Person : NSObject
@property (nonatomic, assign) NSInteger uid;
@property (nonatomic, strong) NSString *name;
@end
@implementation Person
- (instancetype)initWithID:(NSInteger)uid name:(NSString *)name{
if (self = [super init]) {
self.uid = uid;
self.name = name;
}
return self;
}
- (BOOL)isEqual:(Person *)object{
BOOL result;
if (self == object) {
result = YES;
}else{
if (object.uid == self.uid) {
result = YES;
}else{
result = NO;
}
}
NSLog(@"%@ compare with %@ result = %@",self,object,result ? @"Equal":@"NO Equal");
return result;
}
- (NSString *)description{
return [NSString stringWithFormat:@"%p(%ld,%@)",self,self.uid,self.name];
}
- (NSUInteger)hash{
NSUInteger hashValue = self.uid; //在這里只需要比較uid就行。這
樣的話就滿足如果兩個(gè)實(shí)例相等,那么他們的 hash 一定相等,但反過
來hash值相等,那么兩個(gè)實(shí)例不一定相等。但是在 Person 這個(gè)實(shí)例
中,hash值相等那么實(shí)例一定相等。(不考慮繼承之類的)
NSLog(@"hash = %lu,addressValue = %lu,address = %p",(NSUInteger)hashValue,(NSUInteger)self,self);
return hashValue;
}
@end
//調(diào)用重寫hash后的方法
- (void)viewDidLoad {
[super viewDidLoad];
self.mutSet = [NSMutableSet set];
Person *person1 = [[Person alloc] initWithID:1 name:@"nihao"];
Person *person2 = [[Person alloc] initWithID:2 name:@"nihao2"];
NSLog(@"begin add %@",person1);
[self.mutSet addObject:person1];
NSLog(@"after add %@",person1);
NSLog(@"begin add %@",person2);
[self.mutSet addObject:person2];
NSLog(@"after add %@",person2);
NSLog(@"count = %d",self.mutSet.count);
Person *person3 = [[Person alloc] initWithID:1 name:@"nihao"];
NSLog(@"begin add %@",person3);
[self.mutSet addObject:person3];
NSLog(@"after add %@",person3);
NSLog(@"count = %d",self.mutSet.count);
}
6.3.2 關(guān)于一些結(jié)論
NSMutableSet/NSSet中添加 Person 對象的時(shí)候,就會(huì)調(diào)用
- (NSUInteger)hash
方法。NSMutableSet/NSSet中添加 personA 對象的時(shí)候,如果NSMutableSet/NSSet 中之前就已經(jīng)存在 personB對象,且 personB 對象的
- (NSUInteger)hash
返回值和personA的- (NSUInteger)hash
返回值相等, 則 personA 會(huì)繼續(xù)調(diào)用- (BOOL)isEqual:
方法 ,其中此方法以personB為參數(shù);否則不等, 繼續(xù)下一個(gè)元素判斷。具體的判等過程如下。
1、如果 personB 的- (NSUInteger)hash
返回值是否和 personA 的- (NSUInteger)hash
返回值相等,則直接執(zhí)行第 3 步;如果不相等,則執(zhí)行第 2 步。
2、判斷 NSMutableSet/NSSet 中是否存在下一個(gè)沒有比較過的元素,如果有繼續(xù)執(zhí)行第 1 步;如果沒有,則personA 會(huì)被添加到NSMutableSet/NSSet 集合中,執(zhí)行結(jié)束命令。
3、調(diào)用 personA 的- (BOOL)isEqual:
其中該方法 以personB為參數(shù),如果返回結(jié)果為 NO(兩者不相等), 則執(zhí)行第 2 步;如果返回結(jié)果為Yes(兩者是相同元素),則 NSMutableSet/NSSet 中存在和 personA 相同的元素,personA不會(huì)被添加到集合中,直接執(zhí)行結(jié)束命令。
七、總結(jié)
本文章主要講解了 Hash 的設(shè)計(jì)思想、Hash 函數(shù)設(shè)計(jì)、Hash 沖突的產(chǎn)生和解決、 Hash 的優(yōu)缺點(diǎn)以及應(yīng)用,最后結(jié)合實(shí)際代碼,說明了 Hash 在 iOS 判等過程中的實(shí)際應(yīng)用。關(guān)于 Hash 實(shí)際上還有很多值得我們研究的問題,就單單是 Hash 函數(shù)設(shè)計(jì)而言,就足夠我們花上很多功夫去研究,當(dāng)然感興趣的同學(xué)可以去仔細(xì)研究下。