2017年12月,谷歌和麻省理工学院的研究人员发表了一篇关于他们在“学习型指数结构”中的研究报告。这些研究非常令人兴奋,正如作者在摘要中所述:
“我们相信,通过学习模型取代数据管理系统核心组件的想法对未来的系统设计有着深远的影响,而且这项工作只是提供了可能的一瞥。”
事实上,谷歌和麻省理工学院研究人员提出的研究成果包括表明索引世界中的中坚力量新竞争的结果:B-树和哈希图。工程界对机器学习的未来感到不安,因此这篇研究论文已经在Hacker News、Reddit和工程界中进行了深入的探讨。
新的研究是重新审视一个领域基础的绝佳机会,而且作为索引的根本东西往往不是经常性的突破。本文作为哈希表的简介,简要介绍了什么使得它们变得快慢的原因,以及直观的机器学习概念,同时这些概念是如何应用于索引中的。
(如果你已经熟悉哈希表、碰撞处理策略和哈希函数性能考虑因素;你可以跳过本文阅读第二部分,以深入了解这些内容主题。)
为了回应谷歌/麻省理工学院合作的结果,Peter Bailis和一个斯坦福研究小组回顾了基础知识,并警告我们不要扔掉我们的算法书。Bailis和他所在斯坦福大学的团队重新创建了学习型索引策略,并且通过使用名为Cuckoo Hashing的经典哈希表策略,能够在没有任何机器学习的情况下获得类似结果。
在回应谷歌/麻省理工学院的研究中,托马斯·纽曼描述了另一种类似于学习指标策略的性能的方式,而不放弃经过良好测试并且很好理解的B树。这也是谷歌/麻省理工学院团队激动不已的原因,在他们写的论文中:
“重要的是,我们不主张用学习的索引结构来完全取代传统的索引结构。相反,我们创建了一种建立索引的新方法,它补充了现有的工作,并且可以说为未来几十年的领域开辟了一个全新的研究方向。”
那么散列图和B-tree是否注定要成为老龄化的算法?计算机是否会重写算法教科书?如果机器学习策略真的比我们所知道和喜爱的通用索引更好,那么它对计算机世界意味着什么呢?学习指数在什么情况下会超越旧的备用指数?
为了解决这些问题,我们需要了解索引是什么,他们解决了什么问题。
什么是索引?
索引的核心是让事物更容易找到。早在计算机发明之前,人类就一直在索引事物。当我们使用组织良好的文件柜时,我们使用的就是索引系统;全卷百科全书也可被视为一种索引策略;杂货店里的标签过道是一种索引。任何时候我们都有很多东西,我们需要在集合中找到或识别特定的东西,索引可以用来使事情变得更容易。
亚历山德里亚大图书馆的第一个图书管理员Zenodotus负责组织图书馆的庞大的收藏。他设计的系统包括按照流派将书籍分组入房间,并按字母顺序搁置书本。他的同行Callimachus更先进,引入了一个名为pinakes的*目录,它允许图书管理员查找作者,并确定该作者的每本书在图书馆中的位置。(你可以在这里阅读更多关于古代图书馆的信息)。自从1876年发明了杜威十进制系统以来,图书馆索引中创造了很多的创新成果。
在亚历山大图书馆,索引被用来将一段信息(书或作者的名字)映射到图书馆内的一个物理位置。尽管我们的计算机是数字设备,但计算机中的任何特定数据实际上也都映射在一个物理位置。无论是本文的文本、最近的信用卡交易记录还是惊吓猫的视频,数据都存在于计算机上的某个物理位置。
在RAM和固态硬盘驱动器中,数据存储通过一系列许多晶体管传输的电压。在较老的旋转硬盘驱动器中,数据以磁盘格式存储在磁盘的特定圆弧上。当我们将计算机中的信息编入索引时,我们创建了一些算法,将部分数据映射到计算机中的物理位置。在计算机中,被索引的东西总是数据的一部分,索引用于将这些数据映射到它们的地址。
数据库是索引编制的典型用例。数据库旨在保存大量信息,并且一般而言,我们希望高效地检索这些信息。搜索引擎的核心是建立互联网上可用信息的巨大索引。哈希表、二叉查找树、森林,B树和bloom filters都是索引的形式。
很容易想象在亚历山大这样大型图书馆的大厅里找到具体的东西的挑战,而且事实是人类生成的数据的规模正在呈指数级增长。互联网上可用的数据量远远超过任何时代的任何单个图书馆的数量,Google的目标是将所有数据都编入索引。人类为索引创造了许多策略;在这里,我们研究最多产的数据结构,这恰好是一个索引结构:散列表。
什么是散列表?
初看起来,哈希表是基于被称为哈希函数的简单数据结构。散列函数的行为有很多不同并且被用于不同的目的,对于下面的部分,我们将只描述散列表中使用的散列函数,而不是加密散列函数、校验和或任何其他类型的散列函数。
散列函数接受一些输入值(例如一个数字或一些文本)并返回一个整数,我们称之为散列码或散列值。对于任何给定的输入,散列码总是相同的,这只是意味着散列函数必须是确定性的。
在构建哈希表时,我们首先为哈希表分配一些空间(在内存或存储空间中),你可以想象创建一个任意大小的新数组。如果我们有很多数据,我们可能会使用更大的数组;如果我们有更少的数据,我们可以使用更小的数组。任何时候我们想索引一个单独的数据片段,我们都会创建一个键/值对,其中的关键字是关于数据的一些标识信息(例如数据库记录的主键),值是数据本身(整体数据库记录)。
要将值插入散列表中,我们将数据的密钥发送给散列函数。散列函数返回一个整数(散列码),我们使用该整数(以数组的大小为模)作为我们数组中数值的存储索引。如果我们想从哈希表中取回值,我们只需重新计算密钥中的哈希代码并从数组中的该位置获取数据,这个位置是我们数据的物理地址。
在使用杜威十进制图书分类法中,“键/值对”是书本所属的一系列分类,“数据”是书本身。“哈希码”是我们使用杜威十进制过程创建的数值。例如,关于分析几何的书得到了516.3的“哈希码”、自然科学是500、数学是510、几何是516、解析几何是516.3。通过这种方式,杜威十进制系统可以被视为书籍的散列函数;然后将这些书放在与其散列值对应的一组书架上,并按作者的字母顺序排列在书架内。
从根本上说,这个简单的过程全是哈希表。然而,为了确保基于哈希的索引的正确性和效率,在这个简单的思想的基础上构建了大量的复杂性。
基于哈希的索引的性能考虑
散列表中复杂性和优化的主要来源是散列冲突问题。当两个或更多个密钥产生相同的散列码时会发生冲突。考虑这个简单的哈希函数,其中密钥被假定为一个整数:
function hashFunction(key) {
return (key * 13) % sizeOfArray;
}
一个简单的散列函数
虽然任何唯一的整数在乘以13时都会产生唯一的结果,但由于鸽巢原理,最终得到的哈希码仍然会重复。鸽巢原理:如果n个物品放入m个容器中,n>m,则至少一个容器必须包含多个物品。
暂时我们将讨论处理这些不可避免的碰撞的流行策略,但首先应该注意的是,散列函数的选择可以增加或减少碰撞的速率。想象一下,我们总共有16个存储位置,我们必须在这两个散列函数中进行选择:
function hash_a(key) {
return (13 * key) % 16;
}
function hash_b(key){
return (4 * key) % 16;
}
在这种情况下,如果我们要散列数字0-32,hash_b会产生28个冲突; 7个冲突分别用于散列值0,4,8和12(前四个插入没有发生冲突,但是后面的每个插入都会发生)。然而,hash_a会平均分散碰撞,每个索引碰撞一次,总共碰撞16次。这是因为在hash_b中,我们乘以(4)的数字是散列表大小的一个因子(16)。我们在hash_a中选择了一个素数,除非我们的表大小是13的倍数,否则我们不会有用hash_b看到的分组问题。
为了看到这个,你可以运行下面的脚本:
function hash_a(key) {
return (13 * key) % 16;
}
function hash_b(key){
return (4 * key) % 16;
}
let table_a = Array(16).fill(0);
let table_b = Array(16).fill(0);
for(let i = 0; i < 32; i++) {
let hash_code_a = hash_a(i);
let hash_code_b = hash_b(i);
table_a[hash_code_a] += 1;
table_b[hash_code_b] += 1;
}
console.log(table_a); // [2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2]
console.log(table_b); // [8,0,0,0,8,0,0,0,8,0,0,0,8,0,0,0]
更好的散列函数能够避免冲突。
这种哈希策略,将输入密钥乘以素数,实际上是相当常见的。质数减少了输出哈希码与数组大小共有一个公因式的可能性,从而减少了碰撞的可能性。由于哈希表已经存在了相当长的一段时间,因此有很多其他竞争性哈希函数可供选择。
多次移位散列与初始模数策略类似,但是避免了相对昂贵的取模操作,以支持非常快速的移位操作。MurmurHash和Tabulation Hashing是散列函数的多位移系列的强有力替代品。对这些散列函数进行基准测试包括检查它们的计算速度,生成的散列代码的分布以及它们处理不同类型数据(例如除整数以外的字符串和浮点数)的灵活性。有关哈希函数的基准测试套件的示例,请查看SMhasher。
如果我们选择一个好的散列函数,我们可以降低我们的冲突率并且仍然快速计算散列码。不幸的是,无论我们选择什么散列函数,最终我们都会碰撞。决定如何处理冲突将是对我们的哈希表的整体性能产生重大影响。两种常见的碰撞处理策略是链接和线性探测。
链接简单易行。我们不是在散列表的每个索引处存储单个项目,而是存储链接列表的头部指针。任何时候,一个项目通过我们的散列函数与一个已经填充的索引相冲突,我们将它添加为链表中的最后一个元素。查找不再是严格的“恒定时间”,因为我们必须遍历链表来查找任何特定项目。如果我们的散列函数产生很多冲突,我们将会有很长的链,并且由于更长的查找,哈希表的性能会随着时间的推移而降低。
链接:重复的冲突会创建更长的链接列表,但不会占用阵列的任何其他索引
线性探测在概念上仍然很简单,但实施起来很麻烦。在线性探测中,散列表中的每个索引仍保留为单个元素。当索引i发生碰撞时,我们检查索引i + 1是否为空,如果是,我们将数据存储在那里;如果i + 1也有元素,我们检查i + 2,然后i + 3等等,直到找到一个空插槽。只要我们找到一个空插槽,我们插入值。再一次,查找可能不再是严格不变的时间;如果我们在一个索引中存在多个碰撞,那么在我们找到要找的项目之前,我们最终不得不搜索一系列长项目。更重要的是,每当我们发生碰撞时,我们都会增加后续碰撞的机会,因为(与链接不同)传入的项目最终会占据一个新的索引。
线性探测:给定与上面的链接图像相同的数据和散列函数,我们得到一个新的结果。导致碰撞的元素(红色)现在驻留在同一个数组中,并从碰撞索引开始按顺序占据索引。
这可能听起来像链接是更好的选择,但线性探测被广泛接受为具有更好的性能特征。在大多数情况下,这是由于差劲的高速缓存使用链表和数组的有利缓存的利用率。简短版本是检查链表中的所有链接比检查相同大小的数组的所有索引要慢得多。这是因为每个索引在数组中物理上相邻。但是,在链接列表中,每个新节点在创建时都会被赋予一个位置。这个新节点不一定与列表中的邻居物理上相邻。结果是,在链表中,列表顺序中“彼此相邻”的节点很少就我们的RAM芯片内部的实际位置而言,在物理上彼此相邻。由于我们的CPU高速缓存的工作原理,访问相邻内存位置的速度很快,并且随机访问内存位置速度要慢得多。
本文由@阿里云云栖社区组织翻译。
文章原标题《an-introduction-to-hashing-in-the-era-of-machine-learning》,
译者:虎说八道,审校:袁虎。
文章为简译,更为详细的内容,请查看原文。