日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當(dāng)前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

深入理解哈希表

發(fā)布時間:2023/12/13 编程问答 28 豆豆
生活随笔 收集整理的這篇文章主要介紹了 深入理解哈希表 小編覺得挺不錯的,現(xiàn)在分享給大家,幫大家做個參考.

轉(zhuǎn)自:https://bestswifter.com/hashtable/

這篇文章由一個簡單的問題引出:

有兩個字典,分別存有 100 條數(shù)據(jù)和 10000 條數(shù)據(jù),如果用一個不存在的 key 去查找數(shù)據(jù),在哪個字典中速度更快?

有些計(jì)算機(jī)常識的讀者都會立刻回答: “一樣快,底層都用了哈希表,查找的時間復(fù)雜度為 O(1)”。然而實(shí)際情況真的是這樣么?

答案是否定的,存在少部分情況兩者速度不一致,本文首先對哈希表做一個簡短的總結(jié),然后思考 Java 和 Redis 中對哈希表的實(shí)現(xiàn),最后再得出結(jié)論,如果對某個話題已經(jīng)很熟悉,可以直接跳到文章末尾的對比和總結(jié)部分。

Objective-C 中的字典?NSDictionary?底層其實(shí)是一個哈希表,實(shí)際上絕大多數(shù)語言中字典都通過哈希表實(shí)現(xiàn),比如我曾經(jīng)分析過的?Swift 字典的實(shí)現(xiàn)原理。

在討論哈希表之前,先規(guī)范幾個接下來會用到的概念。哈希表的本質(zhì)是一個數(shù)組,數(shù)組中每一個元素稱為一個箱子(bin),箱子中存放的是鍵值對。

哈希表的存儲過程如下:

  • 根據(jù) key 計(jì)算出它的哈希值 h。
  • 假設(shè)箱子的個數(shù)為 n,那么這個鍵值對應(yīng)該放在第?(h % n)?個箱子中。
  • 如果該箱子中已經(jīng)有了鍵值對,就使用開放尋址法或者拉鏈法解決沖突。
  • 在使用拉鏈法解決哈希沖突時,每個箱子其實(shí)是一個鏈表,屬于同一個箱子的所有鍵值對都會排列在鏈表中。

    哈希表還有一個重要的屬性: 負(fù)載因子(load factor),它用來衡量哈希表的?空/滿?程度,一定程度上也可以體現(xiàn)查詢的效率,計(jì)算公式為:

    負(fù)載因子 = 總鍵值對數(shù) / 箱子個數(shù)

    負(fù)載因子越大,意味著哈希表越滿,越容易導(dǎo)致沖突,性能也就越低。因此,一般來說,當(dāng)負(fù)載因子大于某個常數(shù)(可能是 1,或者 0.75 等)時,哈希表將自動擴(kuò)容。

    哈希表在自動擴(kuò)容時,一般會創(chuàng)建兩倍于原來個數(shù)的箱子,因此即使 key 的哈希值不變,對箱子個數(shù)取余的結(jié)果也會發(fā)生改變,因此所有鍵值對的存放位置都有可能發(fā)生改變,這個過程也稱為重哈希(rehash)。

    哈希表的擴(kuò)容并不總是能夠有效解決負(fù)載因子過大的問題。假設(shè)所有 key 的哈希值都一樣,那么即使擴(kuò)容以后他們的位置也不會變化。雖然負(fù)載因子會降低,但實(shí)際存儲在每個箱子中的鏈表長度并不發(fā)生改變,因此也就不能提高哈希表的查詢性能。

    基于以上總結(jié),細(xì)心的讀者可能會發(fā)現(xiàn)哈希表的兩個問題:

  • 如果哈希表中本來箱子就比較多,擴(kuò)容時需要重新哈希并移動數(shù)據(jù),性能影響較大。
  • 如果哈希函數(shù)設(shè)計(jì)不合理,哈希表在極端情況下會變成線性表,性能極低。
  • 我們分別通過 Java 和 Redis 的源碼來理解以上問題,并看看他們的解決方案。

    JDK 的代碼是開源的,可以從這里下載到,我們要找的 HashMap.java 文件的目錄在?openjdk/jdk/src/share/classes/java/util/HashMap.java。

    HashMap 是基于 HashTable 的一種數(shù)據(jù)結(jié)構(gòu),在普通哈希表的基礎(chǔ)上,它支持多線程操作以及空的 key 和 value。

    在 HashMap 中定義了幾個常量:

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 static final int MAXIMUM_CAPACITY = 1 << 30; static final float DEFAULT_LOAD_FACTOR = 0.75f; static final int TREEIFY_THRESHOLD = 8; static final int UNTREEIFY_THRESHOLD = 6; static final int MIN_TREEIFY_CAPACITY = 64;

    依次解釋以上常量:

  • DEFAULT_INITIAL_CAPACITY: 初始容量,也就是默認(rèn)會創(chuàng)建 16 個箱子,箱子的個數(shù)不能太多或太少。如果太少,很容易觸發(fā)擴(kuò)容,如果太多,遍歷哈希表會比較慢。
  • MAXIMUM_CAPACITY: 哈希表最大容量,一般情況下只要內(nèi)存夠用,哈希表不會出現(xiàn)問題。
  • DEFAULT_LOAD_FACTOR: 默認(rèn)的負(fù)載因子。因此初始情況下,當(dāng)鍵值對的數(shù)量大于?16 * 0.75 = 12?時,就會觸發(fā)擴(kuò)容。
  • TREEIFY_THRESHOLD: 上文說過,如果哈希函數(shù)不合理,即使擴(kuò)容也無法減少箱子中鏈表的長度,因此 Java 的處理方案是當(dāng)鏈表太長時,轉(zhuǎn)換成紅黑樹。這個值表示當(dāng)某個箱子中,鏈表長度大于 8 時,有可能會轉(zhuǎn)化成樹。
  • UNTREEIFY_THRESHOLD: 在哈希表擴(kuò)容時,如果發(fā)現(xiàn)鏈表長度小于 6,則會由樹重新退化為鏈表。
  • MIN_TREEIFY_CAPACITY: 在轉(zhuǎn)變成樹之前,還會有一次判斷,只有鍵值對數(shù)量大于 64 才會發(fā)生轉(zhuǎn)換。這是為了避免在哈希表建立初期,多個鍵值對恰好被放入了同一個鏈表中而導(dǎo)致不必要的轉(zhuǎn)化。
  • 學(xué)過概率論的讀者也許知道,理想狀態(tài)下哈希表的每個箱子中,元素的數(shù)量遵守泊松分布:

    當(dāng)負(fù)載因子為 0.75 時,上述公式中 λ 約等于 0.5,因此箱子中元素個數(shù)和概率的關(guān)系如下:

    | 數(shù)量 | 概率 | | :--: |:-----:| | 0 | 0.60653066 | | 1 | 0.30326533 | | 2 | 0.07581633 | | 3 | 0.01263606 | | 4 | 0.00157952 | | 5 | 0.00015795 | | 6 | 0.00001316 | | 7 | 0.00000094 | | 8 | 0.00000006 |

    這就是為什么箱子中鏈表長度超過 8 以后要變成紅黑樹,因?yàn)樵谡G闆r下出現(xiàn)這種現(xiàn)象的幾率小到忽略不計(jì)。一旦出現(xiàn),幾乎可以認(rèn)為是哈希函數(shù)設(shè)計(jì)有問題導(dǎo)致的。

    Java 對哈希表的設(shè)計(jì)一定程度上避免了不恰當(dāng)?shù)墓:瘮?shù)導(dǎo)致的性能問題,每一個箱子中的鏈表可以與紅黑樹切換。

    Redis 是一個高效的 key-value 緩存系統(tǒng),也可以理解為基于鍵值對的數(shù)據(jù)庫。它對哈希表的設(shè)計(jì)有非常多值得學(xué)習(xí)的地方,在不影響源代碼邏輯的前提下我會盡可能簡化,突出重點(diǎn)。

    在 Redis 中,字典是一個?dict?類型的結(jié)構(gòu)體,定義在?src/dict.h?中:

    typedef struct dict { dictht ht[2];long rehashidx; /* rehashing not in progress if rehashidx == -1 */ } dict;

    這里的?dictht?是用于存儲數(shù)據(jù)的結(jié)構(gòu)體。注意到我們定義了一個長度為 2 的數(shù)組,它是為了解決擴(kuò)容時速度較慢而引入的,其原理后面會詳細(xì)介紹,rehashidx?也是在擴(kuò)容時需要用到。先看一下?dictht?的定義:

    typedef struct dictht { dictEntry **table;unsigned long size;unsigned long used; } dictht;

    可見結(jié)構(gòu)體中有一個二維數(shù)組?table,元素類型是?dictEntry,對應(yīng)著存儲的一個鍵值對:

    typedef struct dictEntry { void *key;union {void *val; uint64_t u64; int64_t s64; double d; } v; struct dictEntry *next; } dictEntry;

    從?next?指針以及二維數(shù)組可以看出,Redis 的哈希表采用拉鏈法解決沖突。

    整個字典的層次結(jié)構(gòu)如下圖所示:

    向字典中添加鍵值對的底層實(shí)現(xiàn)如下:

    dictEntry *dictAddRaw(dict *d, void *key) { int index; dictEntry *entry; dictht *ht; if (dictIsRehashing(d)) _dictRehashStep(d); if ((index = _dictKeyIndex(d, key)) == -1) return NULL; ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; entry = zmalloc(sizeof(*entry)); entry->next = ht->table[index]; ht->table[index] = entry; ht->used++; dictSetKey(d, entry, key); return entry; }

    dictIsRehashing?函數(shù)用來判斷哈希表是否正在重新哈希。所謂的重新哈希是指在擴(kuò)容時,原來的鍵值對需要改變位置。為了優(yōu)化重哈希的體驗(yàn),Redis 每次只會移動一個箱子中的內(nèi)容,下一節(jié)會做詳細(xì)解釋。

    仔細(xì)閱讀指針操作部分就會發(fā)現(xiàn),新插入的鍵值對會放在箱子中鏈表的頭部,而不是在尾部繼續(xù)插入。這個細(xì)節(jié)上的改動至少帶來兩個好處:

  • 找到鏈表尾部的時間復(fù)雜度是 O(n),或者需要使用額外的內(nèi)存地址來保存鏈表尾部的位置。頭插法可以節(jié)省插入耗時。
  • 對于一個數(shù)據(jù)庫系統(tǒng)來說,最新插入的數(shù)據(jù)往往更有可能頻繁的被獲取。頭插法可以節(jié)省查找耗時。
  • 所謂的增量式擴(kuò)容是指,當(dāng)需要重哈希時,每次只遷移一個箱子里的鏈表,這樣擴(kuò)容時不會出現(xiàn)性能的大幅度下降。

    為了標(biāo)記哈希表正處于擴(kuò)容階段,我們在?dict?結(jié)構(gòu)體中使用?rehashidx?來表示當(dāng)前正在遷移哪個箱子里的數(shù)據(jù)。由于在結(jié)構(gòu)體中實(shí)際上有兩個哈希表,如果添加新的鍵值對時哈希表正在擴(kuò)容,我們首先從第一個哈希表中遷移一個箱子的數(shù)據(jù)到第二個哈希表中,然后鍵值對會被插入到第二個哈希表中。

    在上面給出的?dictAddRaw?方法的實(shí)現(xiàn)中,有兩句代碼:

    if (dictIsRehashing(d)) _dictRehashStep(d); // ... ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];

    第二句就是用來選擇插入到哪個哈希表中,第一句話則是遷移?rehashidx?位置上的鏈表。它實(shí)際上會調(diào)用?dictRehash(d,1),也就是說是單步長的遷移。dictRehash?函數(shù)的實(shí)現(xiàn)如下:

    int dictRehash(dict *d, int n) { int empty_visits = n*10; /* Max number of empty buckets to visit. */ while(n-- && d->ht[0].used != 0) { dictEntry *de, *nextde; while(d->ht[0].table[d->rehashidx] == NULL) { d->rehashidx++; if (--empty_visits == 0) return 1; } de = d->ht[0].table[d->rehashidx]; /* Move all the keys in this bucket from the old to the new hash HT */ while(de) { unsigned int h; nextde = de->next; /* Get the index in the new hash table */ h = dictHashKey(d, de->key) & d->ht[1].sizemask; de->next = d->ht[1].table[h]; d->ht[1].table[h] = de; d->ht[0].used--; d->ht[1].used++; de = nextde; } d->ht[0].table[d->rehashidx] = NULL; d->rehashidx++; } /* Check if we already rehashed the whole table... */ if (d->ht[0].used == 0) { zfree(d->ht[0].table); d->ht[0] = d->ht[1]; _dictReset(&d->ht[1]); d->rehashidx = -1; return 0; } return 1; }

    這段代碼比較長,但是并不難理解。它由一個 while 循環(huán)和 if 語句組成。在單步遷移的情況下,最外層的 while 循環(huán)沒有意義,而它內(nèi)部又可以分為兩個 while 循環(huán)。

    第一個循環(huán)用來更新?rehashidx?的值,因?yàn)橛行┫渥訛榭?#xff0c;所以?rehashidx?并非每次都比原來前進(jìn)一個位置,而是有可能前進(jìn)幾個位置,但最多不超過 10。第二個循環(huán)則用來復(fù)制鏈表數(shù)據(jù)。

    最外面的 if 判斷中,如果發(fā)現(xiàn)舊哈希表已經(jīng)全部完成遷移,就會釋放舊哈希表的內(nèi)存,同時把新的哈希表賦值給舊的哈希表,最后把?rehashidx?重新設(shè)置為 -1,表示重哈希過程結(jié)束。

    與 Java 不同的是,Redis 提供了?void *?類型 key 的哈希函數(shù),也就是通過任何類型的 key 的指針都可以求出哈希值。具體算法定義在?dictGenHashFunction?函數(shù)中,由于代碼過長,而且都是一些位運(yùn)算,就不展示了。

    它的實(shí)現(xiàn)原理是根據(jù)指針地址和這一塊內(nèi)存的長度,獲取內(nèi)存中的值,并且放入到一個數(shù)組當(dāng)中,可見這個數(shù)組僅由 0 和 1 構(gòu)成。然后再對這些數(shù)字做哈希運(yùn)算。因此即使兩個指針指向的地址不同,但只要其中內(nèi)容相同,就可以得到相同的哈希值。

    首先我們回顧一下 Java 和 Redis 的解決方案。

    Java 的長處在于當(dāng)哈希函數(shù)不合理導(dǎo)致鏈表過長時,會使用紅黑樹來保證插入和查找的效率。缺點(diǎn)是當(dāng)哈希表比較大時,如果擴(kuò)容會導(dǎo)致瞬時效率降低。

    Redis 通過增量式擴(kuò)容解決了這個缺點(diǎn),同時拉鏈法的實(shí)現(xiàn)(放在鏈表頭部)值得我們學(xué)習(xí)。Redis 還提供了一個經(jīng)過嚴(yán)格測試,表現(xiàn)良好的默認(rèn)哈希函數(shù),避免了鏈表過長的問題。

    Objective-C 的實(shí)現(xiàn)和 Java 比較類似,當(dāng)我們需要重寫?isEqual()?方法時,還需要重寫?hash?方法。這兩種語言并沒有提供一個通用的、默認(rèn)的哈希函數(shù),主要是考慮到?isEqual()?方法可能會被重寫,兩個內(nèi)存數(shù)據(jù)不同的對象可能在語義上被認(rèn)為是相同的。如果使用默認(rèn)的哈希函數(shù)就會得到不同的哈希值,這兩個對象就會同時被添加到?NSSet?集合中,這可能違背我們的期望結(jié)果。

    根據(jù)我的了解,Redis 并不支持重寫哈希方法,難道 Redis 就沒有考慮到這個問題么?實(shí)際上還要從 Redis 的定位說起。由于它是一個高效的,Key-Value 存儲系統(tǒng),它的 key 并不會是一個對象,而是一個用來唯一確定對象的標(biāo)記。

    一般情況下,如果要存儲某個用戶的信息,key 的值可能是這樣:?user:100001。Redis 只關(guān)心 key 的內(nèi)存中的數(shù)據(jù),因此只要是可以用二進(jìn)制表示的內(nèi)容都可以作為 key,比如一張圖片。Redis 支持的數(shù)據(jù)結(jié)構(gòu)包括哈希表和集合(Set),但是其中的數(shù)據(jù)類型只能是字符串。因此 Redis 并不存在對象等同性的考慮,也就可以提供默認(rèn)的哈希函數(shù)了。

    Redis、Java、Objective-C 之間的異同再次證明了一點(diǎn):

    沒有完美的架構(gòu),只有滿足需求的架構(gòu)。

    回到文章開頭的問題中來,有兩個字典,分別存有 100 條數(shù)據(jù)和 10000 條數(shù)據(jù),如果用一個不存在的 key 去查找數(shù)據(jù),在哪個字典中速度更快?

    完整的答案是:

    在 Redis 中,得益于自動擴(kuò)容和默認(rèn)哈希函數(shù),兩者查找速度一樣快。在 Java 和 Objective-C 中,如果哈希函數(shù)不合理,返回值過于集中,會導(dǎo)致大字典更慢。Java 由于存在鏈表和紅黑樹互換機(jī)制,搜索時間呈對數(shù)級增長,而非線性增長。在理想的哈希函數(shù)下,無論字典多大,搜索速度都是一樣快。

    最后,整理了一下本文提到的知識點(diǎn),希望大家讀完文章后對以下問題有比較清楚透徹的理解:

  • 哈希表中負(fù)載因子的概念
  • 哈希表擴(kuò)容的過程,以及對查找性能的影響
  • 哈希表擴(kuò)容速度的優(yōu)化,拉鏈法插入新元素的優(yōu)化,鏈表過長時的優(yōu)化
  • 不同語言、使用場景下的取舍
  • OpenJDK Source Release
  • HashMap vs Hashtable vs HashSet
  • 泊松分布
  • Redis Source code
  • An introduction to Redis data types and abstractions
  • 轉(zhuǎn)載于:https://www.cnblogs.com/gotodsp/p/6534699.html

    總結(jié)

    以上是生活随笔為你收集整理的深入理解哈希表的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網(wǎng)站內(nèi)容還不錯,歡迎將生活随笔推薦給好友。