数据结构与算法—一致性哈希

数据结构与算法—一致性哈希 - 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值的相似度。

posted @ 2022-08-06 13:28  CharyGao  阅读(247)  评论(0编辑  收藏  举报