搞iOS的,面試官問Hash幹嘛?原因遠比我下面要介紹的多

在路上重名了啊發表於2019-02-18

一、瞭解hash的重要性

iOS開發中 隨處可見 Hash 的身影,難道我們不好奇嗎?

下圖只是列出了部分知識點Hash在iOS中的應用分析整理)

搞iOS的,面試官問Hash幹嘛?原因遠比我下面要介紹的多

摘自知乎的一句話:

演算法資料結構通訊協議檔案系統驅動等,雖然自己不寫那些東西,但是瞭解其原理對於排錯優化自己的程式碼有很大幫助,就好比雖然你不設計製造汽車,但如果你瞭解發動機、變速器、安全氣囊等幾項原理,對於你駕車如何省油延長使用壽命保證自身安全有很大好處學而不思則罔、思而不學則殆,開發人員就是個隨波而進的行業,無論何時何地,保持學習的深度和廣度對於自身發展是很重要的,誰都不想60歲退休了還停留在增刪查改的層面。

1.1、關聯物件的實現原理:

詳細的原理可以查閱其他資料,這裡只介紹一下實現中使用的基本資料結構。關聯物件採用的是HashMap巢狀HashMap的結構儲存資料的,簡單來說就是根據物件從第一個HashMap中取出儲存物件所有關聯物件的第二個HashMap,然後根據屬性名從第二個HashMap中取出屬性對應的值和策略。

設計關聯物件的初衷是,通過傳入 物件 + 屬性名字 ,就可以找到屬性值。方案設計實現好後,查詢一個物件的關聯物件的基本步驟:

  • - 1、 已知條件一:物件 ,因此引出第一個HashMapAssociationsHashMap),用一個能唯一代表物件的值作為key,用儲存物件的所有關聯物件的結構(名字:值+策略)作為value
  • - 2、 已知條件二:屬性名字 ,因此引出第二個HashMapObjectAssociationMap),用屬性名字作為key,用屬性名字對應的結構體(值+策略)作為value

參考資料:

iOS底層原理總結 - 關聯物件實現原理

關聯物件 AssociatedObject 完全解析

1.2、weak的實現原理:

同樣詳細的原理可以查閱其他資料,這裡只介紹一下實現中使用的基本資料結構。weak採用的是一個全域性的HashMap巢狀陣列的結構儲存資料的,銷燬物件(weak指標指向的物件)的時候,根據物件從HashMap中找到存放所有指向該物件的weak指標的陣列,然後將陣列中的所有元素(weak指標)都置為nil。

weak的最大特點就是在物件銷燬的時候,自動置nil減少訪問野指標的風險,這也是設計weak的初衷。方案設計實現好後,weak指標置nil的基本步驟:

  • - 1、物件dealloc的時候,從全域性的HashMap中,根據一個唯一代表物件的值作為key,找到儲存所有指向該物件的weak指標的陣列

  • - 2、將陣列中的所有元素都置為nil

蘋果對於weak的實現其實類似於通知的實現,指明誰(weak指標)要監聽誰(賦值物件)什麼事件(dealloc操作)執行什麼操作(置nil)。

參考資料:

iOS 底層解析weak的實現原理(包含weak物件的初始化,引用,釋放的分析) weak實現原理

1.3、KVO實現使用的基本資料結構

比較複雜,一個物件可以被n個物件觀察,一物件的n個屬性又可以分別被n個物件觀察。

詳細參考: GNUstep KVC/KVO探索(二):KVO的內部實現

1.4、iOS App簽名的原理

一句話一致性雜湊演算法 + 非對稱加解密演算法

詳細參考: iOS App 簽名的原理

1.5、物件的引用計數儲存的位置

具體參考蘋果iOS系統原始碼思考:物件的引用計數儲存在哪裡?--從runtime原始碼得到的啟示

if 物件支援TaggedPointer {
	return 直接將物件的指標值作為引用計數返回
} 
else if 裝置是64位環境 && Objective-C2.0 {
	return 物件isa指標的一部分空間(bits_extra_rc)
}
else {
	return hash表
}
複製程式碼

1.6、Runloop與執行緒的儲存關係

執行緒和 RunLoop 之間是一一(子執行緒可以沒有)對應的,其關係是儲存在一個全域性的 Dictionary 裡。執行緒剛建立時並沒有 RunLoop,如果你不主動獲取,那它一直都不會有。RunLoop 的建立是發生在第一次獲取時,RunLoop 的銷燬是發生線上程結束時。你只能在一個執行緒的內部獲取其 RunLoop(主執行緒除外)。

1.7、NSDictionary的原理:

解釋完Hash表後,下面簡單解釋下

二、雜湊表

桶排序

2.1、雜湊表定義

雜湊表hash table,也叫雜湊表),是根據鍵(key)直接訪問訪問在記憶體儲存位置的資料結構。 雜湊表本質是一個陣列,陣列中的每一個元素成為一個箱子,箱子中存放的是鍵值對。根據下標index從陣列中取value。關鍵是如何獲取index,這就需要一個固定的函式(雜湊函式),將key轉換成index。不論雜湊函式設計的如何完美,都可能出現不同的key經過hash處理後得到相同的hash值,這時候就需要處理雜湊衝突。

2.2、雜湊表優缺點

優點 :雜湊表可以提供快速的操作。

缺點 :雜湊表通常是基於陣列的,陣列建立後難於擴充套件。 也沒有一種簡便的方法可以以任何一種順序〔例如從小到大)遍歷表中的資料項。

綜上,如果不需要有序遍歷資料,井且可以提前預測資料量的大小。那麼雜湊表在速度和易用性方面是無與倫比的。

2.3、雜湊查詢步驟

  • - 1、使用雜湊函式將被查詢的鍵對映(轉換)為陣列的索引,理想情況下(hash函式設計合理)不同的鍵對映的陣列下標也不同,所有的查詢時間複雜度為O(1)。但是實際情況下不是這樣的,所以雜湊查詢的第二步就是處理雜湊衝突。

  • - 2、處理雜湊碰撞衝突。處理方法有很多,比如拉鍊法、線性探測法。

2.4、雜湊表儲存過程:

  • - 1、使用hash函式根據key得到雜湊值h

  • - 2、如果箱子的個數為n,那麼值應該存放在底(h%n)個箱子中。h%n的值範圍為[0, n-1]。

  • - 3、如果該箱子非空(已經存放了一個值)即不同的key得到了相同的h產生了雜湊衝突,此時需要使用拉鍊法或者開放定址線性探測法解決衝突。

hash("張三") = 23;
hash("李四") = 30;
hash("王五") = 23;
複製程式碼

2.5、常用雜湊函式:

雜湊查詢第一步就是使用雜湊函式將鍵對映成索引。這種對映函式就是雜湊函式。如果我們有一個儲存0-M陣列,那麼我們就需要一個能夠將任意鍵轉換為該陣列範圍內的索引(0~M-1)的雜湊函式。雜湊函式需要易於計算並且能夠均勻分佈所有鍵。比如舉個簡單的例子,使用手機號碼後三位就比前三位作為key更好,因為前三位手機號碼的重複率很高。再比如使用身份證號碼出生年月位數要比使用前幾位數要更好。

在實際中,我們的鍵並不都是數字,有可能是字串,還有可能是幾個值的組合等,所以我們需要實現自己的雜湊函式。

  • - 1、直接定址法
  • - 2、數字分析法
  • - 3、平方取中法
  • - 4、摺疊法
  • - 5、隨機數法
  • - 6、除留餘數法

要想設計一個優秀的雜湊演算法並不容易,根據經驗,總結了需要滿足的幾點要求:

  • 從雜湊值不能反向推匯出原始資料(所以雜湊演算法也叫單向雜湊演算法);
  • 對輸入資料非常敏感,哪怕原始資料只修改了一個 Bit,最後得到的雜湊值也大不相同;
  • 雜湊衝突的概率要很小,對於不同的原始資料,雜湊值相同的概率非常小;
  • 雜湊演算法的執行效率要儘量高效,針對較長的文字,也能快速地計算出雜湊值。

2.6、負載因子 = 總鍵值對數/陣列的個數

負載因子是雜湊表的一個重要屬性,用來衡量雜湊表的空/滿程度,一定程度也可以提現查詢的效率。負載因子越大,意味著雜湊表越滿,越容易導致衝突,效能也就越低。所以當負載因子大於某個常數(一般是0.75)時,雜湊表將自動擴容。雜湊表擴容時,一般會建立兩倍於原來的陣列長度。因此即使key的雜湊值沒有變化,對陣列個數取餘的結果會隨著陣列個數的擴容發生變化,因此鍵值對的位置都有可能發生變化,這個過程也成為重雜湊rehash)。

雜湊表擴容 在陣列比較多的時候,需要重新雜湊並移動資料,效能影響較大。

雜湊表擴容 雖然能夠使負載因子降低,但並不總是能有效提高雜湊表的查詢效能。比如雜湊函式設計的不合理,導致所有的key計算出的雜湊值都相同,那麼即使擴容他們的位置還是在同一條連結串列上,變成了線性表,效能極低,查詢的時候時間複雜度就變成了O(n)

2.7、雜湊衝突的解決方法:

方法一:拉鍊法

簡單來說就是 陣列 + 連結串列 。將鍵通過hash函式對映為大小為M的陣列的下標索引,陣列的每一個元素指向一個連結串列,連結串列中的每一個結點儲存著hash出來的索引值為結點下標的鍵值對。

Java 8解決雜湊衝突採用的就是拉鍊法。在處理雜湊函式設計不合理導致連結串列很長時(連結串列長度超過8切換為紅黑樹,小於6重新退化為連結串列)。將連結串列切換為紅黑樹能夠保證插入和查詢的效率,缺點是當雜湊表比較大時,雜湊表擴容會導致瞬時效率降低。

Redis解決雜湊衝突採用的也是拉鍊法。通過增量式擴容解決了Java 8中的瞬時擴容導致的瞬時效率降低的缺點,同時拉鍊法的實現方式(新插入的鍵值對放在連結串列頭部)帶來了兩個好處:

  • - 一、頭插法可以節省插入耗時。如果插到尾部,則需要時間複雜度為O(n)的操作找到連結串列尾部,或者需要額外的記憶體地址來儲存尾部連結串列的位置。
  • - 二、頭插法可以節省查詢耗時。對於一個資料系統來說,最新插入的資料往往可能頻繁的被查詢。

方法二:開放定址線性探測發

使用兩個大小為N的陣列(一個存放keys,另一個存放values)。使用陣列中的空位解決碰撞,當碰撞發生時(即一個鍵的hash值對應陣列的下標被兩外一個鍵佔用)直接將下標索引加一(index += 1),這樣會出現三種結果:

  • - 1、未命中(陣列下標中的值為空,沒有佔用)。keys[index] = keyvalues[index] = value
  • - 2、命中(陣列下標中的值不為空,佔用)。keys[index] == keyvalues[index] == value
  • - 3、命中(陣列下標中的值不為空,佔用)。keys[index] != key,繼續index += 1,直到遇到結果1或2停止。

拉鍊法的優點

開放定址線性探測發相比,拉鍊法有如下幾個優點:

  • - ①、拉鍊法處理衝突簡單,且無堆積現象,即非同義詞決不會發生衝突,因此平均查詢長度較短;
  • - ②、由於拉鍊法中各連結串列上的結點空間是動態申請的,故它更適合於造表前無法確定表長的情況;
  • - ③、開放定址線性探測發為減少衝突,要求裝填因子α較小,故當結點規模較大時會浪費很多空間。而拉鍊法中可取α≥1,且結點較大時,拉鍊法中增加的指標域可忽略不計,因此節省空間;
  • ④、在用拉鍊法構造的雜湊表中,刪除結點的操作易於實現。只要簡單地刪去連結串列上相應的結點即可。而對開放定址線性探測發構造的雜湊表,刪除結點不能簡單地將被刪結 點的空間置為空,否則將截斷在它之後填人雜湊表的同義詞結點的查詢路徑。這是因為各種開放定址線性探測發中,空地址單元(即開放地址)都是查詢失敗的條件。因此在用開放定址線性探測發處理衝突的雜湊表上執行刪除操作,只能在被刪結點上做刪除標記,而不能真正刪除結點。

拉鍊法的缺點

指標需要額外的空間,故當結點規模較小時,開放定址線性探測發較為節省空間,而若將節省的指標空間用來擴大雜湊表的規模,可使裝填因子變小,這又減少了開放定址線性探測發中的衝突,從而提高平均查詢速度。

開放定址線性探測法缺點

  • - 1、容易產生堆積問題;
  • - 2、不適於大規模的資料儲存;
  • - 3、雜湊函式的設計對衝突會有很大的影響;
  • - 4、插入時可能會出現多次衝突的現象,刪除的元素是多個衝突元素中的一個,需要對後面的元素作處理,實現較複雜;
  • - 5、結點規模很大時會浪費很多空間;

2.8、Hash表的平均查詢長度

Hash表的平均查詢長度包括查詢成功時的平均查詢長度查詢失敗時的平均查詢長度

查詢成功時的平均查詢長度=表中每個元素查詢成功時的比較次數之和/表中元素個數;

查詢不成功時的平均查詢長度相當於在表中查詢元素不成功時的平均比較次數,可以理解為向表中插入某個元素,該元素在每個位置都有可能,然後計算出在每個位置能夠插入時需要比較的次數,再除以表長即為查詢不成功時的平均查詢長度。

例子:

給定一組資料{32,14,23,01,42,20,45,27,55,24,10,53},假設雜湊表的長度為13(最接近n的質數),雜湊函式為H(k) = k%13。分別畫出線性探測法拉鍊法 解決衝突時構造的雜湊表,並求出在等概率下情況,這兩種方法查詢成功和查詢不成功的平均查詢長度。

一、拉鍊法

搞iOS的,面試官問Hash幹嘛?原因遠比我下面要介紹的多
查詢成功時的平均查詢長度:

ASL = (1*6+2*4+3*1+4*1)/12 = 7/4
複製程式碼

查詢不成功時的平均查詢長度:

ASL = (4+2+2+1+2+1)/13
複製程式碼

二、線性探測法

搞iOS的,面試官問Hash幹嘛?原因遠比我下面要介紹的多

查詢成功時查詢次數=插入元素時的比較次數,查詢成功的平均查詢長度

ASL = (1+2+1+4+3+1+1+1+3+9+1+1+3)/12=2.5
複製程式碼

查詢不成功時的查詢次數=第n個位置不成功時的比較次數為,第n個位置到第1個沒有資料位置的距離:如第0個位置取值為1,第1個位置取值為2.查詢不成功時的平均查詢長度:

ASL = (1+2+3+4+5+6+7+8+9+10+11+12)/ 13 = 91/13
複製程式碼

2.9、NSDictionary解釋版本一:是使用NSMapTable實現的,採用拉鍊法解決雜湊衝突

typedef struct {
   NSMapTable        *table;
   NSInteger          i;
   struct _NSMapNode *j;
} NSMapEnumerator;
複製程式碼

上述結構體描述了遍歷一個NSMapTable時的一個指標物件,其中包含table物件自身的指標,計數值,和節點指標。

typedef struct {
   NSUInteger (*hash)(NSMapTable *table,const void *);
   BOOL (*isEqual)(NSMapTable *table,const void *,const void *);
   void (*retain)(NSMapTable *table,const void *);
   void (*release)(NSMapTable *table,void *);
   NSString  *(*describe)(NSMapTable *table,const void *);
   const void *notAKeyMarker;
} NSMapTableKeyCallBacks;
複製程式碼

上述結構體中存放的是幾個函式指標,用於計算keyhash值,判斷key是否相等,retainrelease操作。

typedef struct {
   void       (*retain)(NSMapTable *table,const void *);
   void       (*release)(NSMapTable *table,void *);
   NSString  *(*describe)(NSMapTable *table, const void *);
} NSMapTableValueCallBacks;
複製程式碼

上述存放的三個函式指標,定義在對NSMapTable插入一對key-value時,對value物件的操作。

@interface NSMapTable : NSObject {
   NSMapTableKeyCallBacks   *keyCallBacks;
   NSMapTableValueCallBacks *valueCallBacks;
   NSUInteger             count;
   NSUInteger             nBuckets;
   struct _NSMapNode  **buckets;
}
複製程式碼

從上面的結構真的能看出NSMapTable是一個 雜湊表 + 連結串列 的資料結構嗎?在NSMapTbale中插入或者刪除一個物件的尋找時間 = O(1) + O(m) ,m為最壞時可能為n。

O(1) :對key進行hash得到bucket的位置

O(m) :不同的key得到相同的hash值的value存放到連結串列中,遍歷連結串列的時間

上面的結論和對應的解釋似乎很合理?看看下面的分析再說也不遲!

2.10、NSDictionary解釋版本二:是對_CFDictionary的封裝,解決雜湊衝突使用的是開放定址線性探測法

struct __CFDictionary {
    CFRuntimeBase _base;
    CFIndex _count;
    CFIndex _capacity;
    CFIndex _bucketsNum;
    uintptr_t _marker;
    void *_context;
    CFIndex _deletes;
    CFOptionFlags _xflags;
    const void **_keys;	
    const void **_values;
};
複製程式碼

從上面的資料結構可以看出NSDictionary內部使用了兩個指標陣列分別儲存keysvalues。採用的是連續方式儲存鍵值對。拉鍊法會將keyvalue包裝成一個結果儲存(連結串列結點),而Dictionary的結構擁有keysvalues兩個陣列(開放定址線性探測法解決雜湊衝突的用的就是兩個陣列),說明兩個資料是被分開儲存的,不像是拉鍊法

NSDictionary使用開放定址線性探測法解決雜湊衝突的原理:

可以看到,NSDictionary設定的keyvaluekey值會根據特定的hash函式算出建立的空桶陣列,keysvalues同樣多,然後儲存資料的時候,根據hash函式算出來的值,找到對應的index下標,如果下標已有資料,開放定址法後移動插入,如果空桶陣列到達資料閥值,這個時候就會把空桶陣列擴容,然後重新雜湊插入。

這樣把一些不連續的key-value值插入到了能建立起關係的hash表中,當我們查詢的時候,key根據雜湊>值算出來,然後根據索引,直接index訪問hashkeyshashvalues,這樣查詢速度就可以和連續線性儲存的資料一樣接近O(1)了,只是佔用空間有點大,效能就很強悍。

如果刪除的時候,也會根據_maker標記邏輯上的刪除,除非NSDictionaryNSDictionary本體的hash值就是count)記憶體被移除。

NSDictionary之所以採用這種設計, 其一出於查詢效能的考慮; 其二NSDictionary在使用過程中總是會很快的被釋放,不會長期佔用記憶體;

2.11、Apple方案選擇:

解決雜湊衝突的拉鍊法和開放定址線性探測法,Apple都是用了。具體使用哪一種是根據儲存資料的生命週期和特性決定的。

  • @synchronized使用的是拉鍊法。拉鍊法多用於儲存的資料是通用型別,能夠被反覆利用,就像@synchronized儲存的是鎖是一種無關業務的實現結構,程式執行時多個物件使用同一個鎖的概率相當高,有效的節省了記憶體。
  • weak物件 associatedObject採用的是開放定址線性探測法。開放定址線性探測法用於儲存的資料是臨時的,用完儘快釋放,就像associatedObject,weak。

2.12、NSDictionary的儲存過程:

具體參考我寫的另一篇部落格:iOS筆記:進一步認識 ==、isEqual、hash

  • 1、通過方法- (void)setObject:(id)anObject forKey:(id <NSCopying>)aKey;可以看出key必須遵循NSCopy協議,也就是說NSDictionary的key是copy一份新的,而value是淺拷貝的(如果想深拷貝可以使用NSMapTable)。
  • 2、不過這還不夠,key還必須要繼承NSObject,並且重寫-(NSUInteger)hash;-(BOOL)isEqual:(id)object;兩個方法。第一個函式用於計算hash值,第二個函式用來判斷當雜湊值相同的時候value是否相同(解決雜湊衝突)。

參考部落格

淺談演算法和資料結構: 十一 雜湊表

NSDictionary和NSMutableArray底層原理(雜湊表和環形緩衝區)

Swift中字典的實現原理

深入理解雜湊表

解讀Objective-C的NSDictionary

iOS重做輪子,寫一個NSDictionary(一)

iOS重做輪子,寫一個NSDictionary(一)

雜湊表——線性探測法、鏈地址法、查詢成功、查詢不成功的平均長度

雜湊表、雜湊演算法、一致性雜湊表

細說@synchronized和dispatch_once

相關文章