哈希

发布于 2023-07-21  114 次阅读


本文翻译自博主 Sam 的文章—— Hashing
部分内容的翻译使用了 ChatGPT微软翻译

All content in this post is translated from Sam's article: Hashing. Please click the link to read it.

Some content is translated with the help from ChatGPT and Microsoft Translation.

作为一个程序员,你每天都要使用哈希函数。哈希函数在数据库中被用来优化查询;在数据结构中被用来加速一些操作;在安全领域被用来保证数据安全。你与科技的每一次交互,几乎都与哈希函数有关。

哈希函数是很基础的,任何地方都有它们的身影。

但是,哈希函数的具体定义是什么?它们如何工作呢?

在这篇文章中,我们要揭开哈希函数的神秘面纱。我们会从一个简单的哈希函数开始,然后学习如何测试一个哈希函数的好坏,最后分析真实世界中对哈希函数的一种应用——哈希映射。

什么是哈希函数?

哈希函数是这样一种函数:它接收输入,一般是字符串,然后输出一个数字。如果你用同样的输入多次调用哈希函数,你总会得到同一个数字,并且函数返回的数字总在一定的区间(不论输入如何)。具体的区间取决于哈希函数本身,可能是 32 位整数(对应 0 到 40 亿),也可能更大。

如果我们用 JavaScript 写一个伪哈希函数,它可以长这样:

function hash(input) {
  return 0;
}

即便你不知道哈希函数是如何使用的,上面这个函数也显然是无用的。接下来让我们看看如何评价一个哈希函数的好坏,之后深入了解哈希映射中是如何使用哈希函数的。

什么样的哈希函数是好的哈希函数?

由于输入可以是任意字符串,但返回的数字必须在某个确定的区间内,因此两个不同的输入可能会返回同一个数字。这被称为“哈希碰撞”,而好的哈希函数,就是要最小化可能产生的碰撞。

显然,我们不可能完全消除碰撞。如果我们写一个返回值在 0 到 7 之间的哈希函数,并给它 9 个互不相同的输入,则至少会产生一个碰撞。

hash("to")         == 3
hash("the")        == 2
hash("café")       == 0
hash("de")         == 6
hash("versailles") == 4
hash("for")        == 5
hash("coffee")     == 0
hash("we")         == 7
hash("had")        == 1

上面是某个对 8 取余的哈希函数的输出。不论传入的 9 个值是什么,它总会输出 8 个互不相同的数字中的一个,所以碰撞不可避免。我们的目标是尽可能地减少碰撞。

file

上图是一个例子,不论输入的数字是多少,对 16 取余后,总能在 16 个格子中找到一个位置。

接下来,每当我们输入一个值,得到哈希函数的输出,我们就将对应格子的颜色变深一些。这是一个简单且直观的观察哈希函数避免碰撞的能力的方法。我们追求的是一个美观且平滑的分布。如果一个哈希函数产生的图案有太多的成簇的深色格子,我们就知道这个哈希函数不够好。

Haskie:你说过,当一个哈希函数对两个不同的输入输出相同的值时,这就是碰撞。但是如果我们有一个将输出映射到大范围内的哈希函数,并将它们映射到一个小网格上,我们会不会在网格上创建许多实际上并不是碰撞的碰撞呢?在我们的 8x2 网格上,数字 1 和 17 都映射到第二个方格中。

这是一个很好的观察。你说得完全正确,我们在网格上会创建“伪碰撞”。不过,没关系,因为如果哈希函数很好,我们仍然会看到均匀的分布。将每个方格的值增加 100 与将每个方格的值增加 1 一样好。如果我们有一个糟糕的哈希函数导致很多碰撞,那将会很明显。我们很快就会看到这一点。

下面我们将 1000 个随机生成的字符串映射到一个更大的网格中。

file

这些值的分布十分均匀,因为我们使用了一个好的,著名的哈希函数——murmur3。这个哈希函数在真实世界中被广泛运用,因为它不仅能产生均匀的分布,计算也十分高效。

如果我们使用一个糟糕的哈希函数,网格将会是什么样的?

function hash(input) {
  let hash = 0;
  for (let c of input) {
    hash += c.charCodeAt(0);
  }
  return hash % 1000000;
}

这个哈希函数遍历给定字符串中的每一个字符,并将它们对应的数值累加。最后使用模运算(%)保证最终值处于 0 到 1,000,000 之间。我们不妨将这个哈希函数称为 stringSum

这是它产生的分布(输入是 1000 个随机生成的字符串):

file

这个分布看起来与 murmur3 产生的分布十分不同,是什么导致的?

问题在于给定的字符串是随机生成的。让我们来看看如果输入并非随机生成,而是处于 1 到 1,000 之间的所有整数(字符串),两个哈希函数产生的分布会有什么不同:

file

file

现在问题更清晰了。当输入不是随机生成的时候,stringSum 的分布形成了某种模式。而 murmur3 的分布,与随机生成的输入没什么不同。

如果我们将最常用的 1000 个英语单词哈希化

file

file

虽然更细微,但我们确实在 stringSum 的分布中看出了某种模式。而 murmur3 的分布依然像之前一样。

这就是好的哈希函数的力量:不管输入如何,输出总是均匀分布。接下来我们再讨论一种可视化方法,然后聊聊为什么好的哈希函数很重要。

雪崩效应

另一种评估哈希函数的方法被称为“雪崩效应”。它指的是当输入中的一个比特改变时,输出中有多少个比特改变了。如果一个哈希函数具有好的雪崩效应,那么应当在输入中的单个比特翻转时,输出中平均有 50% 的比特翻转。

正是这个特性使得哈希函数能够避免在分布中形成模式。如果输入中小改变只能引起输出中的小改变,就容易形成模式。模式意味着不好的分布,以及更多的碰撞。

下面我们以一个 8 比特二进制数为例,来可视化雪崩效应。上面的数字是输入,下面的数字是 murmur3 的输出,绿色的数字表示输出中翻转的比特,红色的数字表示未翻转。

file

murmur3 的表现很好,虽然有时候翻转的比特位不足 50%,但平均来说已经达到了。

让我们看看 stringSum 的表现:

file

显然是很糟糕的。输入中翻转一个比特位时,输出中也只有一个比特位翻转。

这些为什么重要

我们已经学习了一些评价哈希函数好坏的方法,但我们还没有讨论这些为什么重要。让我们以哈希映射为例。

为了理解哈希映射,我们先讲讲什么是映射。映射是一种数据结构,它允许你存储键值对。这是一个 JavaScript 的例子:

let map = new Map();
map.set("hello", "world");
console.log(map.get("hello"));

这里我们将一个键值对("hello" → "world")存储到映射中。然后将键“hello”对应的值打印,也就是“world”。

一个更有趣的现实世界中的例子是找异构词。异构词是指由相同字母组成的不同单词。例如 antlers 和 rentals,或者 article和 recital。如果你有一个由单词组成的列表,并且想找到所有异构词,你可以分别将每个单词内的字母排序,然后将其作为一个映射中的键。

let words = [
  "antlers",
  "rentals",
  "sternal",
  "article",
  "recital",
  "flamboyant",
]

let map = new Map();

for (let word of words) {
  let key = word
    .split('')
    .sort()
    .join('');

  if (!map.has(key)) {
    map.set(key, []);
  }
  map.get(key).push(word);
}

这段代码将产生如下映射:

{
  "aelnrst": [
    "antlers",
    "rentals",
    "sternal"
  ],
  "aceilrt": [
    "article",
    "recital"
  ],
  "aabflmnoty": [
    "flamboyant"
  ]
}

实现一个简单的哈希映射

哈希映射是映射的众多实现中的一种,并且实现哈希映射也有很多方法。我们将展示一种最简单的方法,使用一个由列表组成的列表。在现实世界中,内部的列表一般被称为“桶”。哈希函数被用在键上,以决定用哪一个桶来存储键值对,然后将键值对放入该桶中。

让我们逐步来实现一个简单的 JavaScript 哈希映射。我们将从底层开始,先了解一些实用方法,然后再实现 setget 方法。

class HashMap {
  constructor() {
    this.bs = [[], [], []];
  }
}

我们从创建 HashMap 类开始,它带有一个 constructor 方法,用于初始化三个桶

class HashMap {
  // ...
  bucket(key) {
    let h = murmur3(key);
    return this.bs[
      h % this.bs.length
    ];
  }
}

bucket 方法使用 murmur3 来决定给定的键使用哪个桶。这是我们的哈希映射中唯一一个使用哈希函数的地方。

class HashMap {
  // ...
  entry(bucket, key) {
    for (let e of bucket) {
      if (e.key === key) {
        return e;
      }
    }
    return null;
  }
}

entry 方法以 bucketkey 作为参数,扫描给定的桶,并返回含有给定键的条目,如果不存在,则返回 null

class HashMap {
  // ...
  set(key, value) {
    let b = this.bucket(key);
    let e = this.entry(b, key);
    if (e) {
      e.value = value;
      return;
    }
    b.push({ key, value });
  }
}

set 方法接受一个键值对,并将其存储在我们的哈希映射中。它通过使用我们之前创建的 bucketentry 方法来实现。如果找到了已有的条目,其值将被覆盖。如果没有找到已有的条目,键值对将被添加到映射中。在 JavaScript 中,{ key, value }{ key: key, value: value } 的简写形式。

class HashMap {
  // ...
  get(key) {
    let b = this.bucket(key);
    let e = this.entry(b, key);
    if (e) {
      return e.value;
    }
    return null;
  }
}

get 方法类似于 set 方法。它使用 bucket 方法和 entry 方法找到 key 关联的条目,就像 set 方法做的那样。如果条目存在,就将其对应值返回。如果条目不存在,则返回 null

代码稍微有点多。你需要理解的是,我们的哈希映射是一个列表的列表,哈希函数被用于决定使用哪个列表来存储和读取给定的键。

下图是一个示例:

file

由于我们使用 murmur3 作为哈希函数,桶间的分布应当是十分均匀的。虽然有时候会有不平衡,但通常是比较均匀的。

为了从哈希映射中读取一个值,我们要先将键哈希化,以决定从哪个桶中读取值。然后在该桶内所有的键中找到给定的键。

正是通过哈希化,我们将这个搜索步骤最小化,这也是为什么 murmur3 被优化用于提高速度。哈希函数越快,我们找到正确的桶进行搜索的速度就越快,整体上我们的哈希映射也就越快。

这也是为什么减少碰撞是十分重要的。如果我们使用文章开头的哈希函数(总是返回 0),我们将会把所有键值对存储在第一个桶中。寻找任意键意味着要检查哈希映射中的所有值。使用一个具有良好分布的哈希函数,我们将搜索成本降低到 1/N,N 代表桶的数目。

让我们看看 stringSum 的效果:

file

有趣的是,好像这个分布也不错。虽然也能注意到一些模式,但整体来说还可以。

Haskie:终于!stringSum 胜利了。我就知道它总会派上用场的。

不要这么急,Haskie。我们需要谈论一个严重的问题。在这些连续的数字上,分布看起来还不错,但我们已经看到 stringSum 没有良好的雪崩效应。这不会有好结果。

真实世界中的碰撞

让我们来看看两个真实世界中的数据:IP 地址和英语单词。我们将会使用 100,000,000 个随机 IP 地址,以及 466,550 个英文单词,分别使用 murmur3stringSum 将它们哈希化,并看看有多少碰撞产生。

IP 地址:

murmur3 stringSum
碰撞总数 1,156,959 99,999,566
占比 1.157% 99.999%

英文单词:

murmur3 stringSum
碰撞总数 25 464,220
占比 0.005% 99.5%

当我们在现实世界使用哈希映射,我们通常不是用它们存储随机值。我们可以想象,在服务器的限速代码中计算我们已经遇到某个 IP 地址的次数。或者在整个历史上统计书中单词出现的次数,以追踪它们的来源和流行程度。stringSum 对于这些应用来说效果很差,因为它的碰撞率非常高。

人为碰撞

现在是一些关于 murmur3 的坏消息。我们不仅需要担心由于输入的相似性导致的碰撞问题。看看这个例子。

file

file

发生了什么?为什么这些乱七八糟的字符会被映射成同一个数字?

我使用 murmur3 将 141 兆个随机字符串哈希化,来找到那些被 murmur3 映射为数字 1228476406 的值。哈希函数对特定的输入总是返回同样的输出,所有使用暴力法找到碰撞是可行的。

Haskie:对不起,141 兆?就像……141 后面跟着 12 个 0?

是的,这花了我 25 分钟。计算机是很快的

如果你的软件使用用户输入构建哈希映射,那么恶意行为者能够轻易地获得碰撞,这可能会带来灾难性的后果。以 HTTP 标头为例。一个 HTTP 请求像这个样子:

GET / HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: google.com

你不用看懂所有东西,只要知道第一行是请求的路径,以及剩余行是标头。标头是键值对,所以 HTTP 服务器倾向于使用映射去存储它们。我们可以传递任何我们想传递的标头,因此我们可以恶意地传递一些我们明知道会导致碰撞的标头。这会显著降低服务器的运行速度。

这并不仅仅是理论上的。如果你搜索“哈希 DoS”,你将会发现许多这类例子。2000 年代中期就存在大量此类事件。

有许多方法缓解 HTTP 服务器的压力,比如忽略乱七八糟的标头键,限制存储的标头数量。但是现代哈希函数,如 murmur3 提供了更通用的解决方案:随机化。

在前面的部分,我们展示了一些哈希函数的实现。这些实现使用了单个参数:input。现代的许多哈希函数会使用第二个参数:seed,有时候也叫 salt。在 murmur 中,这个 seed 是一个数字。

目前为止,我们都用 0 作为种子,让我们来看看如果使用 1 作为种子,会有怎样的变化。

file

就像这样,从 0 到 1,碰撞问题消失了。这就是种子的作用:它以不可预测的方式使哈希函数的输出随机化。它是如何实现的超出了本文的范围,所有的哈希函数都会以自己的方式实现这一点。

对于相同的输入,哈希函数仍然返回相同的输出,只不过输出是输入和种子的组合。在使用一个种子时发生的碰撞,在使用另一个种子时不应当再发生。编程语言通常会在程序启动时生成随机数字作为种子,所以你每次运行你的程序时,种子都不一样。作为一个坏人,在不知道种子的情况下,就无法造成损害了。

如果你仔细观察上面的可视化图和之前的图,哈希函数的输入是相同的,但它们产生了不同的哈希值。这意味着,如果你用一个种子来哈希一个值,并希望在将来能够对比它,你需要确保使用相同的种子。

对于使用哈希映射的情况来说,不同种子产生不同的值并不会影响效果,因为哈希映射只在程序运行期间存在。只要在程序的整个生命周期中使用相同的种子,你的哈希映射将继续正常工作。如果你在程序之外存储了哈希值,比如存入文件中,你需要小心确保知道使用了哪个种子。

Playground

作为一个传统,我为你制作了一个 playground 来编写你自己的哈希函数,并在网格中可视化它们的效果。点击这里来尝试下吧!

结论

我们已经知道了什么是哈希函数,学习了一些评价哈希函数好坏的方法,了解了当哈希函数不够好时会引发的问题,以及一些别有用心的人会如何利用哈希函数搞破坏。

哈希函数的世界非常广阔,在这篇文章中我们只是触及了表面。我们还没有讨论过加密哈希与非加密哈希的区别,我们只涉及了成千上万种哈希函数用途中的其中一种,而且我们还没有详细讨论现代哈希函数的实际工作原理。

如果你真的对这个话题非常感兴趣,并希望学习更多,那么下面是一些推荐阅读:

  • https://github.com/rurban/smhasher 这个仓库包含了测试哈希函数好坏的黄金标准。它对大量的哈希函数进行了各种测试,并将结果呈现在一个大表格中。虽然其中的所有测试内容可能有些难以理解,但这就是哈希测试的最新技术发展所在。
  • https://djhworld.github.io/hyperloglog/ 这是一个关于名为 HyperLogLog 的数据结构的交互式介绍。它被用于高效地计算非常大的集合中唯一元素的数量。它运用了哈希的方法,以非常巧妙的方式完成计数任务。
  • https://www.gnu.org/software/gperf/ 当给定所需哈希的预期元素集合时,这个程序能够自动生成一个“完美”的哈希函数。

快来加入 Hacker News 上的讨论吧!

Acknowledgements

感谢每一个阅读了本文早期草稿,以及提供了宝贵意见的人:

以及每一个帮助我找到 murmur3 哈希碰撞的人:

Patreon

经过 Load BanlancingMemory Allocation 的成功,我已经决定开设一个 Patreon 主页:https://patreon.com/samwho 。在接下来的所有文章中,我将发布一篇仅供 Patreon 支持者查看的幕后文章,其中将讨论每篇文章的决策、困难和所学到的经验。这将让你深入了解这些文章的发展过程,我对为这篇文章撰写的幕后文章感到非常兴奋。

如果你喜欢我的写作,并希望继续支持,我会非常感激你成为我的 Patreon 赞助者。❤️

  • alipay_img
  • wechat_img
最后更新于 2023-07-21