數據結構與算法—一致性哈希

數據結構與算法—一致性哈希 - Java 技術驛站

一致性哈希算法在1997年由麻省理工學院提出的一種分佈式哈希(DHT)實現算法,設計目標是爲了解決因特網中的熱點(Hot spot)問題,初衷和CARP十分類似。一致性哈希修正了CARP使用的簡單哈希算法帶來的問題,使得分佈式哈希(DHT)可以在P2P環境中真正得到應用。

Hash算法

一致性hash算法提出了在動態變化的Cache環境中,判定哈希算法好壞的四個定義:

  1. 平衡性(Balance) :平衡性是指哈希的結果能夠儘可能分佈到所有的緩衝中去,這樣可以使得所有的緩衝空間都得到利用。很多哈希算法都能夠滿足這一條件。
  2. 單調性(Monotonicity) :單調性是指如果已經有一些內容通過哈希分派到了相應的緩衝中,又有新的緩衝加入到系統中。哈希的結果應能夠保證原有已分配的內容可以被映射到原有的或者新的緩衝中去,而不會被映射到舊的緩衝集合中的其他緩衝區。
  3. 分散性(Spread) :在分佈式環境中,終端有可能看不到所有的緩衝,而是隻能看到其中的一部分。當終端希望通過哈希過程將內容映射到緩衝上時,由於不同終端所見的緩衝範圍有可能不同,從而導致哈希的結果不一致,最終的結果是相同的內容被不同的終端映射到不同的緩衝區中。這種情況顯然是應該避免的,因爲它導致相同內容被存儲到不同緩衝中去,降低了系統存儲的效率。分散性的定義就是上述情況發生的嚴重程度。好的哈希算法應能夠儘量避免不一致的情況發生,也就是儘量降低分散性。
  4. 負載(Load) :負載問題實際上是從另一個角度看待分散性問題。既然不同的終端可能將相同的內容映射到不同的緩衝區中,那麼對於一個特定的緩衝區而言,也可能被不同的用戶映射爲不同 的內容。與分散性一樣,這種情況也是應當避免的,因此好的哈希算法應能夠儘量降低緩衝的負荷。

假設一個簡單的場景:有4個cache服務器(後簡稱cache)組成的集羣,當一個對象object傳入集羣時,這個對象應該存儲在哪一個cache裏呢?一種簡單的方法是使用映射公式:

  1. Hash(object) % 4

然後考慮以下情況:這個算法就可以保證任何object都會盡可能隨機落在其中一個cache中。一切運行正常。

由於流量增大,需要增加一臺cache,共5個cache。這時,映射公式就變成 Hash(object) % 5 。
有一個cache服務器down掉,變成3個cache。這時,映射公式就變成 Hash(object) % 3 。
可見,無論新增還是減少節點,都會改變映射公式,而由於映射公式改變,幾乎所有的object都會被映射到新的cache中,這意味着一時間所有的緩存全部失效。 大量的數據請求落在app層甚至是db層上,這樣嚴重的違反了單調性原則,這對服務器的影響當然是災難性的。

所以,普通的哈希算法(也稱硬哈希)採用簡單取模的方式,將機器進行散列,這在cache環境不變的情況下能取得讓人滿意的結果,但是當cache環境動態變化時,這種靜態取模的方式顯然就不滿足單調性的要求(當增加或減少一臺機子時,幾乎所有的存儲內容都要被重新散列到別的緩衝區中)。

一致性Hash算法

一致性哈希算法有多種具體的實現,包括Chord算法KAD算法等實現,以上的算法的實現都比較複雜。下面介紹一種網上廣爲流傳的一致性哈希算法的基本實現原理。

1、環形Hash空間

按照常用的hash算法來將對應的key哈希到一個具有2^32次方個桶的空間中,即0至(232)−10至(232)−1的數字空間中。現在我們可以將這些數字頭尾相連,想象成一個閉合的環形。如下圖

202202131609236421.png

2、數據映射

把數據通過一定的hash算法處理後映射到環。現在我們將object1、object2、object3、object4四個對象通過特定的Hash函數計算出對應的key值,然後散列到Hash環上。如下圖:

  1. Hash(object1) = key1
  2. Hash(object2) = key2
  3. Hash(object3) = key3
  4. Hash(object4) = key4

202202131609240732.png

3、機器映射

將機器通過hash算法映射到環上。在採用一致性哈希算法的分佈式集羣中將新的機器加入,其原理是通過使用與對象存儲一樣的Hash算法將機器也映射到環中(一般情況下對機器的hash計算是採用機器的IP或者機器唯一的別名作爲輸入值),然後以順時針的方向計算,將所有對象存儲到離自己最近的機器中。

假設現在有NODE1,NODE2,NODE3三臺機器,通過Hash算法得到對應的KEY值,映射到環中,其示意圖如下:

  1. Hash(NODE1) = KEY1;
  2. Hash(NODE2) = KEY2;
  3. Hash(NODE3) = KEY3;

202202131609245733.png

通過上圖可以看出對象與機器處於同一哈希空間中,這樣按順時針轉動object1存儲到了NODE1中,object3存儲到了NODE2中,object2、object4存儲到了NODE3中。

在這樣的部署環境中,hash環是不會變更的,因此,通過算出對象的hash值就能快速的定位到對應的機器中,這樣就能找到對象真正的存儲位置了。

4、機器的刪除與添加

普通hash求餘算法最爲不妥的地方就是在有機器的添加或者刪除之後會照成大量的對象存儲位置失效,這樣就大大的不滿足單調性了。下面來分析一下一致性哈希算法是如何處理的。

a. 節點(機器)的刪除

以上面的分佈爲例,如果NODE2出現故障被刪除了,那麼按照順時針遷移的方法,object3將會被遷移到NODE3中,這樣僅僅是object3的映射位置發生了變化,其它的對象沒有任何的改動。如下圖:

202202131609252284.png

202202131609257915.png

202202131609264726.png

圖中的A1、A2、B1、B2、C1、C2、D1、D2都是虛擬節點,機器A負載存儲A1、A2的數據,機器B負載存儲B1、B2的數據,機器C負載存儲C1、C2的數據。由於這些虛擬節點數量很多,均勻分佈,因此不會造成“雪崩”現象。

b. 節點(機器)的添加

如果往集羣中添加一個新的節點NODE4,通過對應的哈希算法得到KEY4,並映射到環中,如下圖:

202202131609272867.png

通過按順時針遷移的規則,那麼object2被遷移到了NODE4中,其它對象還保持這原有的存儲位置。

通過對節點的添加和刪除的分析,一致性哈希算法在保持了單調性的同時,還是數據的遷移達到了最小,這樣的算法對分佈式集羣來說是非常合適的,避免了大量數據遷移,減小了服務器的的壓力。

5、平衡性

根據上面的圖解分析,一致性哈希算法滿足了單調性和負載均衡的特性以及一般hash算法的分散性,但這還並不能當做其被廣泛應用的原由,因爲還缺少了平衡性。

下面將分析一致性哈希算法是如何滿足平衡性的。hash算法是不保證平衡的,如上面只部署了NODE1和NODE3的情況(NODE2被刪除的圖),object1存儲到了NODE1中,而object2、object3、object4都存儲到了NODE3中,這樣就照成了非常不平衡的狀態。

6、虛擬節點

其實,理論上,只要cache足夠多,每個cache在圓環上就會足夠分散。但是在真實場景裏,cache服務器只會有很少,所以,在一致性哈希算法中,爲了儘可能的滿足平衡性,其引入了虛擬節點。

“虛擬節點”( virtual node )是實際節點(機器)在 hash 空間的複製品( replica )一 實際個節點(機器)對應了若干個“虛擬節點”,這個對應個數也成爲“複製個數”,“虛擬節點”在 hash 空間中以hash值排列。 即可想象在這個環上有很多“虛擬節點”,數據的存儲是沿着環的順時針方向找一個虛擬節點,每個虛擬節點都會關聯到一個真實節點。

以上面只部署了NODE1和NODE3的情況(NODE2被刪除的圖)爲例,之前的對象在機器上的分佈很不均衡,現在我們以2個副本(複製個數)爲例,這樣整個hash環中就存在了4個虛擬節點,最後對象映射的關係圖如下:

202202131609278178.png

根據上圖可知對象的映射關係:

  1. object1->NODE1-1object2->NODE1-2object3->NODE3-2object4->NODE3-1

通過虛擬節點的引入,對象的分佈就比較均衡了。那麼在實際操作中,真正的對象查詢是如何工作的呢?對象從hash到虛擬節點到實際節點的轉換如下圖:

202202131609283749.png

“虛擬節點”的hash計算可以採用對應節點的IP地址加數字後綴的方式。

例如假設NODE1的IP地址爲192.168.1.100。引入“虛擬節點”前,計算 cache A 的 hash 值:

  1. Hash(“192.168.1.100”);

引入“虛擬節點”後,計算“虛擬節”點NODE1-1和NODE1-2的hash值:

  1. Hash(“192.168.1.100#1”); // NODE1-1
  2. Hash(“192.168.1.100#2”); // NODE1-2

參考與推薦:

1、https://blog.csdn.net/cywosp/article/details/23397179

2、http://blog.huanghao.me/?p=14

3、http://www.zsythink.net/archives/1182

數據結構與算法—Trie樹

Trie,又經常叫前綴樹,字典樹等等。它有很多變種,如後綴樹,Radix Tree/Trie,PATRICIA tree,以及bitwise版本的crit-bit tree。當然很多名字的意義其實有交叉。

Trie樹是一種非常重要的數據結構,它在信息檢索,字符串匹配等領域有廣泛的應用,同時,它也是很多算法和複雜數據結構的基礎,如後綴樹,AC自動機等。

典型應用是用於統計和排序大量的字符串(但不僅限於字符串),所以經常被搜索引擎系統用於 文本詞頻統計 。

它的 優點 是:最大限度地減少無謂的字符串比較,查詢效率比哈希表高。
Trie的核心思想是 空間換時間 。利用字符串的公共前綴來降低查詢時間的開銷以達到提高效率的目的。
Trie樹也有它的 缺點 ,Trie樹的內存消耗非常大.當然,或許用左兒子右兄弟的方法建樹的話,可能會好點.

什麼是Trie樹

Trie樹 ,又叫 字典樹、前綴樹 (Prefix Tree)、單詞查找樹或鍵樹,是一種多叉樹結構。

字典樹(Trie)可以保存一些字符串->值的對應關係。基本上,它跟 Java 的 HashMap 功能相同,都是 key-value 映射,只不過 Trie 的 key 只能是 字符串 。是一種哈希樹的變種。

它有3個基本性質:

  1. 根節點不包含字符,除根節點外每一個節點都只包含一個字符。
  2. 從根節點到某一節點,路徑上經過的字符連接起來,爲該節點對應的字符串。
  3. 每個節點的所有子節點包含的字符都不相同。

通常在實現的時候,會在節點結構中設置一個標誌,用來標記該結點處是否構成一個單詞(關鍵字)。

可以看出,Trie樹的關鍵字一般都是字符串,而且Trie樹把每個關鍵字保存在一條路徑上,而不是一個結點中。另外,兩個有公共前綴的關鍵字,在Trie樹中前綴部分的路徑相同,所以Trie樹又叫做前綴樹(Prefix Tree)。

Trie的強大之處就在於它的時間複雜度,插入和查詢的效率很高,都爲O(K),其中K 是待插入/查詢的字符串的長度,而與Trie中保存了多少個元素無關。

關於查詢,會有人說 hash 表時間複雜度是O(1)不是更快?但是,哈希搜索的效率通常取決於 hash 函數的好壞,若一個壞的 hash 函數導致很多的衝突,效率並不一定比Trie樹高。

而Trie樹中不同的關鍵字就不會產生衝突。它只有在允許一個關鍵字關聯多個值的情況下才有類似hash碰撞發生。

此外,Trie樹不用求 hash 值,對短字符串有更快的速度。因爲通常,求hash值也是需要遍歷字符串的。

Trie樹可以對關鍵字按字典序排序。

舉一個例子。給出一組單詞,inn, int, at, age, adv, ant, 我們可以得到下面的Trie:

202202131609291521.png

可以看出:

  • 每條邊對應一個字母。
  • 每個節點對應一項前綴。葉節點對應最長前綴,即 單詞本身 。
  • 單詞inn與單詞int有共同的前綴“in”, 因此他們共享左邊的一條分支,root->i->in。同理,ate, age, adv, 和ant共享前綴"a",所以他們共享從根節點到節點"a"的邊。

Trie樹的應用

1、字符串檢索

給出N個單詞組成的熟詞表,以及一篇全用小寫英文書寫的文章,請你按最早出現的順序寫出所有不在熟詞表中的生詞。

檢索/查詢 功能是Trie樹最原始的功能。給定一組字符串,查找某個字符串是否出現過,思路就是從根節點開始一個一個字符進行比較:

  • 如果沿路比較,發現不同的字符,則表示該字符串在集合中不存在。
  • 如果所有的字符全部比較完並且全部相同,還需判斷最後一個節點的標誌位(標記該節點是否代表一個關鍵字)。
  1. struct trie_node
  2. {
  3. bool isKey; // 標記該節點是否代表一個關鍵字
  4. trie_node *children[26]; // 各個子節點
  5. };

 

2、詞頻統計

Trie樹常被搜索引擎系統用於文本詞頻統計 。

  1. struct trie_node
  2. {
  3. int count; // 記錄該節點代表的單詞的個數
  4. trie_node *children[26]; // 各個子節點
  5. };

思路:爲了實現詞頻統計,我們修改了節點結構,用一個整型變量count來計數。對每一個關鍵字執行插入操作,若已存在,計數加1,若不存在,插入後count置1。

3、排序

Trie樹可以對大量字符串按字典序進行排序,思路也很簡單:
給定N個互不相同的僅由一個單詞構成的英文名,讓你將他們按字典序從小到大輸出。用字典樹進行排序,採用數組的方式創建字典樹,這棵樹的每個結點的所有兒子很顯然地按照其字母大小排序。對這棵樹進行先序遍歷即可。

4、前綴匹配

例如:找出一個字符串集合中所有以ab開頭的字符串。我們只需要用所有字符串構造一個trie樹,然後輸出以a−>b−>開頭的路徑上的關鍵字即可。

trie樹前綴匹配常用於搜索提示。如當輸入一個網址,可以自動搜索出可能的選擇。當沒有完全匹配的搜索結果,可以返回前綴最相似的可能

5、最長公共前綴

查找一組字符串的最長公共前綴,只需要將這組字符串 構建成Trie樹 ,然後從跟節點開始 遍歷 ,直到出現多個節點爲止(即出現分叉)。

舉例說明:給出N 個小寫英文字母串,以及Q 個詢問,即詢問某兩個串的最長公共前綴的長度是多少?

解決方案:首先對所有的串建立其對應的字母樹。此時發現,對於兩個串的最長公共前綴的長度即它們所在結點的 公共祖先個數 ,於是,問題就轉化爲了離線(Offline)的最近公共祖先(Least Common Ancestor,簡稱 LCA )問題。而最近公共祖先問題同樣是一個經典問題,可以用下面幾種方法:

  1. 利用並查集(Disjoint Set),可以採用採用經典的Tarjan 算法;
  2. 求出字母樹的歐拉序列(Euler Sequence )後,就可以轉爲經典的最小值查詢(Range Minimum Query,簡稱RMQ)問題了;

關於並查集,Tarjan算法,RMQ問題,網上有很多資料。

6、作爲輔助結構

如後綴樹,AC自動機等。

7、應用實例

  1. 一個文本文件,大約有一萬行,每行一個詞,要求統計出其中最頻繁出現的前10個詞,請給出思想,給出時間複雜度分析。

    • 之前在此文:海量數據處理面試題集錦與Bit-map詳解中給出的參考答案:用trie樹統計每個詞出現的次數,時間複雜度是 O(n le) ( l e 表示單詞的平均長度),然後是找出出現最頻繁的前10個詞。也可以用堆來實現(具體的操作可參考第三章、尋找最小的k個數),時間複雜度是(le表示單詞的平均長度),然後是找出出現最頻繁的前10個詞。也可以用堆來實現(具體的操作可參考第三章、尋找最小的k個數),時間複雜度是O(nlg10)。所以總的時間複雜度,是O(nlg10)。所以總的時間複雜度是O(nle)與與O(nlg10)中較大的哪一個。
  2. 有一個1G大小的一個文件,裏面每一行是一個詞,詞的大小不超過16字節,內存限制大小是1M。返回頻數最高的100個詞。

  3. 1000萬字符串,其中有些是重複的,需要把重複的全部去掉,保留沒有重複的字符串。請怎麼設計和實現?

  4. 一個文本文件,大約有一萬行,每行一個詞,要求統計出其中最頻繁出現的前10個詞,請給出思想,給出時間複雜度分析。

  5. 尋找熱門查詢:搜索引擎會通過日誌文件把用戶每次檢索使用的所有檢索串都記錄下來,每個查詢串的長度爲1-255字節。假設目前有一千萬個記錄,這些查詢串的重複讀比較高,雖然總數是1千萬,但是如果去除重複和,不超過3百萬個。一個查詢串的重複度越高,說明查詢它的用戶越多,也就越熱門。請你統計最熱門的10個查詢串,要求使用的內存不能超過1G。

    • (1) 請描述你解決這個問題的思路;
    • (2) 請給出主要的處理流程,算法,以及算法的複雜度。

Trie樹的實現

Trie樹的插入、刪除、查找的操作都是一樣的,只需要簡單的對樹進行一遍遍歷即可,時間複雜度:O(n)(n是字符串的長度)。

trie樹每一層的節點數是26 i 級別的。所以爲了節省空間,對於Tried樹的實現可以使用數組和鏈表兩種方式。空間的花費,不會超過單詞數×單詞長度。

  1. 數組:由於我們知道一個Tried樹節點的子節點的數量是固定26個(針對不同情況會不同,比如兼容數字,則是36等),所以可以使用固定長度的數組來保存節點的子節點

    • 優點:在對子節點進行查找時速度快
    • 缺點:浪費空間,不管子節點有多少個,總是需要分配26個空間
  2. 鏈表:使用鏈表的話我們需要在每個子節點中保存其兄弟節點的鏈接,當我們在一個節點的子節點中查找是否存在一個字符時,需要先找到其子節點,然後順着子節點的鏈表從左往右進行遍歷

    • 優點:節省空間,有多少個子節點就佔用多少空間,不會造成空間浪費
    • 缺點:對子節點進行查找相對較慢,需要進行鏈表遍歷,同時實現也較數組麻煩

Java實現:

202202131609296942.png

202202131609301673.png

  1. import java.util.ArrayList;
  2. import java.util.List;
  3. /**
  4. * 單詞查找樹
  5. */
  6. class Trie {
  7. /** 單詞查找樹根節點,根節點爲一個空的節點 */
  8. private Vertex root = new Vertex();
  9. /** 單詞查找樹的節點(內部類) */
  10. private class Vertex {
  11. /** 單詞出現次數統計 */
  12. int wordCount;
  13. /** 以某個前綴開頭的單詞,它的出現次數 */
  14. int prefixCount;
  15. /** 子節點用數組表示 */
  16. Vertex[] vertexs = new Vertex[26];
  17. /**
  18. * 樹節點的構造函數
  19. */
  20. public Vertex() {
  21. wordCount = 0;
  22. prefixCount = 0;
  23. }
  24. }
  25. /**
  26. * 單詞查找樹構造函數
  27. */
  28. public Trie() {
  29. }
  30. /**
  31. * 向單詞查找樹添加一個新單詞
  32. *
  33. * @param word
  34. * 單詞
  35. */
  36. public void addWord(String word) {
  37. addWord(root, word.toLowerCase());
  38. }
  39. /**
  40. * 向單詞查找樹添加一個新單詞
  41. *
  42. * @param root
  43. * 單詞查找樹節點
  44. * @param word
  45. * 單詞
  46. */
  47. private void addWord(Vertex vertex, String word) {
  48. if (word.length() == 0) {
  49. vertex.wordCount++;
  50. } else if (word.length() > 0) {
  51. vertex.prefixCount++;
  52. char c = word.charAt(0);
  53. int index = c - 'a';
  54. if (null == vertex.vertexs[index]) {
  55. vertex.vertexs[index] = new Vertex();
  56. }
  57. addWord(vertex.vertexs[index], word.substring(1));
  58. }
  59. }
  60. /**
  61. * 統計某個單詞出現次數
  62. *
  63. * @param word
  64. * 單詞
  65. * @return 出現次數
  66. */
  67. public int countWord(String word) {
  68. return countWord(root, word);
  69. }
  70. /**
  71. * 統計某個單詞出現次數
  72. *
  73. * @param root
  74. * 單詞查找樹節點
  75. * @param word
  76. * 單詞
  77. * @return 出現次數
  78. */
  79. private int countWord(Vertex vertex, String word) {
  80. if (word.length() == 0) {
  81. return vertex.wordCount;
  82. } else {
  83. char c = word.charAt(0);
  84. int index = c - 'a';
  85. if (null == vertex.vertexs[index]) {
  86. return 0;
  87. } else {
  88. return countWord(vertex.vertexs[index], word.substring(1));
  89. }
  90. }
  91. }
  92. /**
  93. * 統計以某個前綴開始的單詞,它的出現次數
  94. *
  95. * @param word
  96. * 前綴
  97. * @return 出現次數
  98. */
  99. public int countPrefix(String word) {
  100. return countPrefix(root, word);
  101. }
  102. /**
  103. * 統計以某個前綴開始的單詞,它的出現次數(前綴本身不算在內)
  104. *
  105. * @param root
  106. * 單詞查找樹節點
  107. * @param word
  108. * 前綴
  109. * @return 出現次數
  110. */
  111. private int countPrefix(Vertex vertex, String prefixSegment) {
  112. if (prefixSegment.length() == 0) {
  113. return vertex.prefixCount;
  114. } else {
  115. char c = prefixSegment.charAt(0);
  116. int index = c - 'a';
  117. if (null == vertex.vertexs[index]) {
  118. return 0;
  119. } else {
  120. return countPrefix(vertex.vertexs[index], prefixSegment.substring(1));
  121. }
  122. }
  123. }
  124. /**
  125. * 調用深度遞歸算法得到所有單詞
  126. * @return 單詞集合
  127. */
  128. public List<String> listAllWords() {
  129. List<String> allWords = new ArrayList<String>();
  130. return depthSearchWords(allWords, root, "");
  131. }
  132. /**
  133. * 遞歸生成所有單詞
  134. * @param allWords 單詞集合
  135. * @param vertex 單詞查找樹的節點
  136. * @param wordSegment 單詞片段
  137. * @return 單詞集合
  138. */
  139. private List<String> depthSearchWords(List<String> allWords, Vertex vertex,
  140. String wordSegment) {
  141. Vertex[] vertexs = vertex.vertexs;
  142. for (int i = 0; i < vertexs.length; i++) {
  143. if (null != vertexs[i]) {
  144. if (vertexs[i].wordCount > 0) {
  145. allWords.add(wordSegment + (char)(i + 'a'));
  146. if(vertexs[i].prefixCount > 0){
  147. depthSearchWords(allWords, vertexs[i], wordSegment + (char)(i + 'a'));
  148. }
  149. } else {
  150. depthSearchWords(allWords, vertexs[i], wordSegment + (char)(i + 'a'));
  151. }
  152. }
  153. }
  154. return allWords;
  155. }
  156. }
  157. public class Main {
  158. public static void main(String[] args) {
  159. Trie trie = new Trie();
  160. trie.addWord("abc");
  161. trie.addWord("abcd");
  162. trie.addWord("abcde");
  163. trie.addWord("abcdef");
  164. System.out.println(trie.countPrefix("abc"));
  165. System.out.println(trie.countWord("abc"));
  166. System.out.println(trie.listAllWords());
  167. }
  168. }

數據結構與算法—布隆過濾器

引入

什麼情況下需要布隆過濾器?我們先來看幾個比較常見的例子:

  • 字處理軟件中,需要檢查一個英語單詞是否拼寫正確
  • 在 FBI,一個嫌疑人的名字是否已經在嫌疑名單上
  • 在網絡爬蟲裏,一個網址是否被訪問過
  • yahoo, gmail等郵箱垃圾郵件過濾功能

這幾個例子有一個共同的特點: 如何判斷一個元素是否存在一個集合中?

常規思路與侷限

如果想判斷一個元素是不是在一個集合裏,一般想到的是將集合中所有元素保存起來,然後通過比較確定。鏈表、樹、散列表(又叫哈希表,Hash table)等等數據結構都是這種思路。但是隨着集合中元素的增加,我們需要的存儲空間越來越大。同時檢索速度也越來越慢。

  • 數組
  • 鏈表
  • 樹、平衡二叉樹、Trie
  • Map (紅黑樹)
  • 哈希表

雖然上面描述的這幾種數據結構配合常見的排序、二分搜索可以快速高效的處理絕大部分判斷元素是否存在集合中的需求。但是當集合裏面的元素數量足夠大,如果有500萬條記錄甚至1億條記錄呢?這個時候常規的數據結構的問題就凸顯出來了。

數組、鏈表、樹等數據結構會存儲元素的內容,一旦數據量過大,消耗的內存也會呈現線性增長,最終達到瓶頸。

有的同學可能會問,哈希表不是效率很高嗎?查詢效率可以達到O(1)。但是哈希表需要消耗的內存依然很高。使用哈希表存儲一億 個垃圾 email 地址的消耗?哈希表的做法:首先,哈希函數將一個email地址映射成8字節信息指紋;考慮到哈希表存儲效率通常小於50%(哈希衝突);因此消耗的內存:821億 字節 = 1.6G 內存,普通計算機是無法提供如此大的內存。這個時候,布隆過濾器(Bloom Filter)就應運而生。在繼續介紹布隆過濾器的原理時,先講解下關於哈希函數的預備知識。

哈希函數

哈希函數的概念是:將任意大小的數據轉換成特定大小的數據的函數,轉換後的數據稱爲哈希值或哈希編碼。

一個應用是Hash table(散列表,也叫哈希表),是根據哈希值 (Key value) 而直接進行訪問的數據結構。也就是說,它通過把哈希值映射到表中一個位置來訪問記錄,以加快查找的速度。下面是一個典型的 hash 函數 / 表示意圖:

202202131609306881.png

可以明顯的看到,原始數據經過哈希函數的映射後稱爲了一個個的哈希編碼,數據得到壓縮。哈希函數是實現哈希表和布隆過濾器的基礎。

哈希函數有以下兩個特點:

  • 如果兩個散列值是不相同的(根據同一函數),那麼這兩個散列值的原始輸入也是不相同的。
  • 散列函數的輸入和輸出不是唯一對應關係的,如果兩個散列值相同,兩個輸入值很可能是相同的。但也可能不同,這種情況稱爲 “散列碰撞”(或者 “散列衝突”)。

缺點 : 引用吳軍博士的《數學之美》中所言,哈希表的空間效率還是不夠高。如果用哈希表存儲一億個垃圾郵件地址,每個email地址 對應 8bytes, 而哈希表的存儲效率一般只有50%,因此一個email地址需要佔用16bytes. 因此一億個email地址佔用1.6GB,如果存儲幾十億個email address則需要上百GB的內存。除非是超級計算機,一般的服務器是無法存儲的。

所以要引入下面的 Bloom Filter。

布隆過濾器(Bloom Filter)

布隆過濾器(英語:Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的二進制向量和一系列隨機映射函數。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是 空間效率和查詢時間都遠遠超過一般的算法,缺點是有一定的誤識別率和刪除困難。

1、原理

布隆過濾器(Bloom Filter)的核心實現是一個超大的位數組和幾個哈希函數。假設位數組的長度爲m,哈希函數的個數爲k。下圖中是k=3時的布隆過濾器。

202202131609313142.png

以上圖爲例,具體的操作流程:假設集合裏面有3個元素{x, y, z},哈希函數的個數爲3。首先將位數組進行初始化,將裏面每個位都設置位0。

對於集合裏面的每一個元素,將元素依次通過3個哈希函數進行映射,每次映射都會產生一個哈希值,這個值對應位數組上面的一個點,然後將位數組對應的位置標記爲1。查詢W元素是否存在集合中的時候,同樣的方法將W通過哈希映射到位數組上的3個點。如果3個點的其中有一個點不爲1,則可以判斷該元素 一定 不存在集合中。反之,如果3個點都爲1,則該元素可能存在集合中。

注意:此處不能判斷該元素是否一定存在集合中,可能存在一定的誤判率。可以從圖中可以看到:假設某個元素通過映射對應下標爲4,5,6這3個點。雖然這3個點都爲1,但是很明顯這3個點是不同元素經過哈希得到的位置,因此這種情況說明元素雖然不在集合中,也可能對應的都是1,這是誤判率存在的原因。

那麼布隆過濾器的誤差有多少?我們假設所有哈希函數散列足夠均勻,散列後落到Bitmap每個位置的概率均等。Bitmap的大小爲m、原始數集大小爲n、哈希函數個數爲k:

  • 1個散列函數時,接收一個元素時Bitmap中某一位置爲0的概率爲: 1−1/m

  • k個相互獨立的散列函數,接收一個元素時Bitmap中某一位置爲0的概率爲: (1−1/m)k

  • 假設原始集合中,所有元素都不相等(最嚴格的情況),將所有元素都輸入布隆過濾器,此時某一位置仍爲0的概率爲:(1−1/m)nk , 某一位置爲1的概率爲:

    1−(1−1/m)nk

  • 當我們對某個元素進行判重時,誤判即這個元素對應的k個標誌位不全爲1,但所有k個標誌位都被置爲1,誤判率ε約爲:

ε≈[1−(1−1/m)nk]k

算法:

  1. 首先需要k個hash函數,每個函數可以把key散列成爲1個整數
  2. 初始化時,需要一個長度爲n比特的數組,每個比特位初始化爲0
  3. 某個key加入集合時,用k個hash函數計算出k個散列值,並把數組中對應的比特位置爲1
  4. 判斷某個key是否在集合時,用k個hash函數計算出k個散列值,並查詢數組中對應的比特位,如果所有的比特位都是1,認爲在集合中。

2、添加與查詢

布隆過濾器添加元素

  • 將要添加的元素給k個哈希函數
  • 得到對應於位數組上的k個位置
  • 將這k個位置設爲1

布隆過濾器查詢元素

  • 將要查詢的元素給k個哈希函數
  • 得到對應於位數組上的k個位置
  • 如果k個位置有一個爲0,則 肯定 不在集合中
  • 如果k個位置全部爲1,則 可能 在集合中

4、優點

相比於其它的數據結構,布隆過濾器在空間和時間方面都有巨大的優勢。布隆過濾器存儲空間和插入/查詢時間都是常數(O(k))。另外,散列函數相互之間沒有關係,方便由硬件並行實現。布隆過濾器不需要存儲元素本身,在某些對保密要求非常嚴格的場合有優勢。

布隆過濾器可以表示全集,其它任何數據結構都不能;

4、缺點

但是布隆過濾器的缺點和優點一樣明顯。誤算率是其中之一。隨着存入的元素數量增加,誤算率隨之增加。但是如果元素數量太少,則使用散列表足矣。

誤判補救方法是:再建立一個小的白名單,存儲那些可能被誤判的信息。

另外,一般情況下不能從布隆過濾器中刪除元素. 我們很容易想到把位數組變成整數數組,每插入一個元素相應的計數器加 1, 這樣刪除元素時將計數器減掉就可以了。然而要保證安全地刪除元素並非如此簡單。首先我們必須保證刪除的元素的確在布隆過濾器裏面. 這一點單憑這個過濾器是無法保證的。另外計數器迴繞也會造成問題。

5、實例

可以快速且空間效率高的判斷一個元素是否屬於一個集合;用來實現數據字典,或者集合求交集。

又如: 檢測垃圾郵件

再如:

分析 :如果允許有一定的錯誤率,可以使用 Bloom filter,4G 內存大概可以表示 340 億 bit。將其中一個文件中的 url 使用 Bloom filter 映射爲這 340 億 bit,然後挨個讀取另外一個文件的 url,檢查是否與 Bloom filter,如果是,那麼該 url 應該是共同的 url(注意會有一定的錯誤率)。”

6、實現

  1. import mmh3 #mmh3 非加密型哈希算法,一般用於哈希檢索操作
  2. from bitarray import bitarray
  3. class Zarten_BloomFilter():
  4. def __init__(self):
  5. self.capacity = 1000
  6. self.bit_array = bitarray(self.capacity)
  7. self.bit_array.setall(0)
  8. def add(self, element):
  9. position_list = self._handle_position(element)
  10. for position in position_list:
  11. self.bit_array[position] = 1
  12. def is_exist(self, element):
  13. position_list = self._handle_position(element)
  14. result = True
  15. for position in position_list:
  16. result = self.bit_array[position] and result
  17. return result
  18. def _handle_position(self, element):
  19. postion_list = []
  20. for i in range(41, 51):
  21. index = mmh3.hash(element, i) % self.capacity
  22. postion_list.append(index)
  23. return postion_list
  24. if __name__ == '__main__':
  25. bloom = Zarten_BloomFilter()
  26. a = ['when', 'how', 'where', 'too', 'there', 'to', 'when']
  27. for i in a:
  28. bloom.add(i)
  29. b = ['when', 'xixi', 'haha']
  30. for i in b:
  31. if bloom.is_exist(i):
  32. print('%s exist' % i)
  33. else:
  34. print('%s not exist' % i)

數據結構與算法—simhash

    

引入

隨着信息爆炸時代的來臨,互聯網上充斥着着大量的近重複信息,有效地識別它們是一個很有意義的課題。

例如,對於搜索引擎的爬蟲系統來說,收錄重複的網頁是毫無意義的,只會造成存儲和計算資源的浪費;

同時,展示重複的信息對於用戶來說也並不是最好的體驗。造成網頁近重複的可能原因主要包括:

  • 鏡像網站
  • 內容複製
  • 嵌入廣告
  • 計數改變
  • 少量修改

一個簡化的爬蟲系統架構如下圖所示:

202202131609320301.png

事實上,傳統 比較兩個文本相似性的方法 ,大多是將文本分詞之後,轉化爲特徵向量距離的度量,比如常見的歐氏距離、海明距離或者餘弦角度等等。兩兩比較固然能很好地適應,但這種方法的一個最大的缺點就是,無法將其擴展到海量數據。例如,試想像Google那種收錄了數以幾十億互聯網信息的大型搜索引擎,每天都會通過爬蟲的方式爲自己的索引庫新增的數百萬網頁,如果待收錄每一條數據都去和網頁庫裏面的每條記錄算一下餘弦角度,其計算量是相當恐怖的。

我們考慮採用爲每一個web文檔通過hash的方式生成一個指紋(fingerprint)。傳統的加密式hash,比如md5,其設計的目的是爲了讓整個分佈儘可能地均勻,輸入內容哪怕只有輕微變化,hash就會發生很大地變化。我們理想當中的哈希函數,需要對幾乎相同的輸入內容,產生相同或者相近的hashcode,換句話說,hashcode的相似程度要能直接反映輸入內容的相似程度。很明顯,前面所說的md5等傳統hash無法滿足我們的需求。

simhash的原理

simhash是locality sensitive hash(局部敏感哈希)的一種,最早由Moses Charikar在《similarity estimation techniques from rounding algorithms》一文中提出。Google就是基於此算法實現網頁文件查重的。simhash算法的主要思想是降維,將高維的特徵向量映射成一個f-bit的指紋(fingerprint),通過比較兩篇文章的f-bit指紋的Hamming Distance來確定文章是否重複或者高度近似。我們假設有以下三段文本:

  • the cat sat on the mat
  • the cat sat on a mat
  • we all scream for ice cream

使用傳統hash可能會產生如下的結果:

  1. irb(main):006:0> p1 = 'the cat sat on the mat'
  2. irb(main):005:0> p2 = 'the cat sat on a mat'
  3. irb(main):007:0> p3 = 'we all scream for ice cream'
  4. irb(main):007:0> p1.hash
  5. => 415542861
  6. irb(main):007:0> p2.hash
  7. => 668720516
  8. irb(main):007:0> p3.hash
  9. => 767429688

使用simhash會應該產生類似如下的結果:

  1. irb(main):003:0> p1.simhash
  2. => 851459198
  3.  00110010110000000011110001111110 
  4. irb(main):004:0> p2.simhash
  5. => 847263864
  6.  00110010100000000011100001111000 
  7. irb(main):002:0> p3.simhash
  8. => 984968088
  9.  00111010101101010110101110011000 

海明距離的定義,爲 兩個二進制串中不同位的數量 。上述三個文本的simhash結果,其兩兩之間的海明距離爲(p1,p2)=4,(p1,p3)=16以及(p2,p3)=12。事實上,這正好符合文本之間的相似度,p1和p2間的相似度要遠大於與p3的。

如何實現這種hash算法呢?圖解如下:

202202131609333752.png

算法過程大概如下(5個步驟:分詞、hash、加權、合併、降維):

  1. 將Doc進行關鍵詞抽取(其中包括分詞和計算權重),抽取出n個(關鍵詞,權重)對, 即圖中的(feature, weight)們。 記爲 feature_weight_pairs = [fw1, fw2 … fwn],其中 fwn = (feature_n,weight_n)。
  2. hash_weight_pairs = [ (hash(feature), weight) for feature, weight in feature_weight_pairs ]生成圖中的(hash,weight)們, 此時假設hash生成的位數bits_count = 6(如圖);
  3. 然後對hash_weight_pairs進行位的縱向累加,如果該位是1,則+weight,如果是0,則-weight,最後生成bits_count個數字,如圖所示是[13, 108, -22, -5, -32, 55], 這裏產生的值和hash函數所用的算法相關。
  4. [13,108,-22,-5,-32,55] -> 110001這個就很簡單啦, 正1負0 。

海明距離

當我們算出所有doc的simhash值之後,需要計算doc A和doc B之間是否相似的條件是:A和B的海明距離是否小於等於n,這個n值根據經驗一般取值爲3,

那海明距離怎麼計算呢?二進制串A 和 二進制串B 的海明距離 就是 A xor B 後二進制中1的個數。

  1. 舉例如下:
  2. A = 100111;
  3. B = 101010;
  4. hamming_distance(A, B) = count_1(A xor B) = count_1(001101) = 3;

simhash本質上是局部敏感性的hash,和md5之類的不一樣。 正因爲它的局部敏感性,所以我們可以使用海明距離來衡量simhash值的相似度。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章