Jay Wengrow - A Common-Sense Guide to Data Structures and Algorithms【自译】第8章

第8章哈希表实现的极速查找

想象一下,你正在编写一个程序,允许顾客从餐厅点餐快餐,而你正在实现一个包含食物及其价格的菜单。你可以技术上使用一个数组:

menu = [ ["french fries", 0.75], ["hamburger", 2.5], ["hot dog", 1.5], ["soda", 0.6] ]

这个数组包含多个子数组,每个子数组包含两个元素。第一个元素是表示菜单上食物的字符串,第二个元素表示该食物的价格。

正如你在《为什么算法很重要》中学到的那样,如果这个数组是无序的,查找给定食物的价格将需要O(N)步,因为计算机需要执行线性搜索。如果它是有序数组,计算机可以进行二分搜索,这将需要O(log N)的时间。

虽然O(log N)并不差,但我们可以做得更好。事实上,我们可以做得更好。通过本章的学习,你将了解如何使用一种称为哈希表的特殊数据结构,在O(1)时间内进行数据查找。了解哈希表在内部如何工作以及使用它们的合适场景,你可以在许多情况下充分利用它们惊人的查找速度。

哈希表

大多数编程语言都包含一种名为哈希表(hash table)的数据结构,它具有一个惊人的超能力:快速读取。需要注意的是,在不同的编程语言中,哈希表有不同的称呼。其他的称呼包括散列(hashes)、映射(maps)、哈希映射(hash maps)、字典(dictionaries)和关联数组(associative arrays)。

以下是使用 Ruby 实现的菜单示例,采用了哈希表的结构:

menu = { "french fries" => 0.75, "hamburger" => 2.5, "hot dog" => 1.5, "soda" => 0.6 }

哈希表是一组成对的值。每对中的第一个项目称为键(key),第二个项目称为值(value)。在哈希表中,键和值彼此有着重要的关联。在这个例子中,“french fries”是键,0.75是值。它们配对在一起表示了薯条的价格是0.75美元。

在 Ruby 中,你可以使用以下语法查找键的值:

menu["french fries"]

这将返回值0.75。

在哈希表中查找一个值的效率平均为O(1),因为通常只需一步。让我们来看看为什么。

哈希函数的哈希化

记得小时候用来创建和解密消息的那些秘密代码吗?

例如,这是一种简单的将字母映射到数字的方式:

A = 1
B = 2
C = 3
D = 4
E = 5
等等。

按照这个代码规则,
ACE 转换为 135,
CAB 转换为 312,
DAB 转换为 412,
以及
BAD 转换为 214。

将字符转换为数字的过程被称为哈希化。用于将这些字母转换为特定数字的代码称为哈希函数。

除了这个之外,还有许多其他的哈希函数。另一个例子是将每个字母对应的数字相加并返回总和。如果我们这样做,BAD 将成为数字7,经历了两个步骤:
步骤1:首先,BAD 转换为 214。
步骤2:然后我们对每个数字求和:
2 + 1 + 4 = 7

另一个哈希函数的例子是返回所有字母对应数字的乘积。这将把单词 BAD 转换为数字8:
步骤1:首先,BAD 转换为 214。
步骤2:然后我们计算这些数字的乘积:
2 * 1 * 4 = 8

在本章剩余的示例中,我们将坚持使用这个哈希函数的最后一个版本。现实世界中的哈希函数比这个复杂得多,但这个“乘法”哈希函数将保持我们的示例清晰简单。

事实上,哈希函数只需要满足一个标准就可以有效:哈希函数在每次应用时都必须将相同的字符串转换为相同的数字。如果哈希函数对于给定的字符串返回不一致的结果,则它是无效的。

无效哈希函数的示例包括使用随机数或当前时间作为计算的一部分。使用这些函数,BAD 可能一次转换为12,另一次转换为106。

然而,使用我们的“乘法”哈希函数,BAD 永远会转换为8。这是因为 B 总是2,A 总是1,D 总是4。而 2 * 1 * 4 总是8。没有别的可能性。

请注意,使用这个哈希函数,DAB 也会像BAD一样转换为8。这实际上会导致一些问题,我稍后会解释。

掌握了哈希函数的概念,我们现在可以理解哈希表的实际工作原理了。

构建一个有趣且有利可图的同义词词库,但主要是为了盈利

在晚上和周末,你独自忙碌地开发一款隐秘的初创企业,这将彻底改变世界。这是一个…同义词词典应用。但这不是普通的同义词词典应用——这是Quickasaurus。你知道它将彻底颠覆数十亿美元的同义词词典市场。当用户在Quickasaurus中查找一个词时,它会返回一个同义词,而不是老式同义词词典应用中返回的所有可能同义词。

由于每个单词都有一个关联的同义词,这是哈希表的一个绝佳用例。毕竟,哈希表是一组成对的项目。让我们开始吧。

我们可以使用哈希表来表示我们的同义词词典:
thesaurus = {}

在内部,哈希表将其数据存储在一排排的单元格中,类似于数组。每个单元格都有对应的编号。例如:

在这里插入图片描述

(我们省略了索引0,因为根据我们的“乘法”哈希函数,那里不会存储任何内容。)

让我们将第一个条目添加到哈希表中:
thesaurus[“bad”] = “evil”

在代码中,我们的哈希表现在是这样的:
{“bad” => “evil”}

让我们来探索一下哈希表是如何存储这些数据的。
首先,计算机将哈希函数应用于键。同样,我们将使用先前描述的“乘法”哈希函数。因此,这将计算为:
BAD = 2 * 1 * 4 = 8

由于我们的键(“bad”)哈希到了8,计算机将值(“evil”)放置在单元格8中。

在这里插入图片描述

现在,让我们再添加另一个键值对:
thesaurus[“cab”] = “taxi”

同样,计算机对键进行哈希运算:
CAB = 3 * 1 * 2 = 6

由于结果值是6,计算机将值(“taxi”)存储在单元格6中。

在这里插入图片描述

让我们再添加一个键值对:
thesaurus[“ace”] = “star”

总结一下这里发生的事情:对于每个键值对,每个值都存储在键的索引位置,在对键进行哈希处理之后。

ACE 哈希为15,因为 ACE = 1 * 3 * 5 = 15,所以"star"被放置在单元格15中。

在这里插入图片描述

在代码中,我们的哈希表目前看起来是这样的:
{“bad” => “evil”, “cab” => “taxi”, “ace” => “star”}

哈希表查找

当我们从哈希表中查找条目时,我们使用一个键来找到其关联的值。让我们看看在我们的Quickasaurus示例哈希表中是如何工作的。

假设我们想查找与键“bad”关联的值。在我们的代码中,我们会这样写:
thesaurus[“bad”]

为了找到与“bad”关联的值,计算机执行了两个简单的步骤:

  1. 计算机对我们要查找的键进行哈希运算:BAD = 2 * 1 * 4 = 8。
  2. 由于结果是8,计算机查找并返回存储在单元格8中的值。在这种情况下,那就是字符串“evil”。

让我们退后一步,看看整体的情况。在哈希表中,每个值的放置由其键决定。也就是说,通过对键进行哈希处理,我们计算出了关联值应该放置的索引号。

因为键决定了值的放置,我们利用这个原理来使查找变得轻而易举。当我们有任何键并想找到其值时,键本身告诉我们值将被找到的位置。就像我们对键进行哈希处理以将值插入到适当的单元格中一样,我们可以再次对键进行哈希处理,以找到我们先前放置该值的位置。

现在我们可以清楚地理解为什么在哈希表中查找值通常是 O(1):这是一个需要恒定时间的过程。计算机对键进行哈希处理,将其转换为一个数字,并跳转到具有该数字的索引位置来检索存储在那里的值。

现在我们可以理解为什么哈希表对于我们餐厅菜单的查找比数组要快。使用数组时,当我们查找菜单项的价格时,我们必须搜索每个单元格直到找到它。对于无序数组,这最多需要 O(N),对于有序数组,这最多需要 O(log N)。然而,使用哈希表,我们现在可以使用实际的菜单项作为键,使我们可以进行 O(1) 的哈希表查找。
这就是哈希表的美妙之处。

单向查找

要指出的重要一点是,仅在我们知道值的键的情况下,才能在哈希表中以一步的方式找到任何值。如果我们尝试在不知道其键的情况下查找特定值,我们仍然必须查找哈希表中的每个键值对,这是 O(N)。

同样,仅当使用键来查找值时,我们才能进行 O(1) 的查找。但是,如果我们想使用值来查找其关联的键,我们就无法利用哈希表的快速查找能力。

这是因为哈希表的整个前提是键决定了值的位置。但这个前提只在一个方向上有效:我们使用键来查找值。值并不确定键的位置,所以我们无法轻松地找到任何键,除非遍历所有键。

想想看,键存储在哪里呢?在之前的图表中,我们只看到值是如何存储在哈希表中的。

虽然这个细节可能因语言而异,但一些语言会将键存储在值旁边。这在发生冲突时很有用,我将在下一节中讨论。

无论如何,哈希表单向性的另一个方面值得注意。每个键在哈希表中只能存在一次,但一个值可以有多个实例。

如果我们考虑本章开头的菜单示例,我们不能将汉堡包列两次(也不应该,因为它只有一个价格)。但是,我们可以有多个价格为 $2.50 的食物。

在许多语言中,如果我们尝试存储一个键值对,其中键已经存在,它就会简单地覆盖旧值,同时保持相同的键。

处理冲突

Hash tables确实很棒,但也不是没有问题的。

继续我们的同义词词典示例:如果我们想要将以下条目添加到我们的词典中,会发生什么?

thesaurus[“dab”] = “pat”

首先,计算机会对键进行哈希处理:

DAB = 4 * 1 * 2 = 8

然后,它会尝试将 “pat” 添加到我们哈希表的第 8 个单元格中:

在这里插入图片描述

哎呀。第 8 个单元格已经被 “evil” 占用了!

尝试向已经被占用的单元格添加数据被称为冲突。幸运的是,有解决方法。

处理冲突的一种经典方法是称为“分离链接”。
当发生冲突时,它不是将单个值放入单元格中,而是将对数组的引用放入其中。

让我们仔细观察一下我们哈希表底层数据存储的一个子部分:

在这里插入图片描述

在我们的例子中,计算机想要将 “pat” 添加到第 8 个单元格,但它已经包含了 “evil”。所以,它用一个数组替换了单元格 8 的内容,如图所示。

在这里插入图片描述

这个数组包含子数组,其中第一个值是单词,第二个值是其同义词。

让我们来看看在这种情况下哈希表查找是如何工作的。如果我们查找:
thesaurus[“dab”]
计算机执行以下步骤:

  1. 对键进行哈希处理:DAB = 4 * 1 * 2 = 8。
  2. 查找第 8 个单元格。计算机注意到第 8 个单元格包含的是一个数组,而不是单个值。
  3. 它通过数组进行线性搜索,查看每个子数组的索引 0,直到找到我们的键(“dab”)。然后返回正确子数组中索引 1 的值。

让我们通过视觉方式逐步了解这些步骤。

在这里插入图片描述

我们将 DAB 哈希为 8,所以计算机检查该单元格:

由于第 8 个单元格包含一系列子数组,我们开始通过每个子数组进行线性搜索,从第一个子数组开始。我们检查第一个子数组的索引 0:

在这里插入图片描述

它不包含我们正在寻找的键(“dab”),所以我们继续查看下一个子数组的索引 0:

在这里插入图片描述

我们找到了 “dab”,这表明该子数组中索引 1 的值(“pat”)就是我们要找的值。

在计算机遇到引用数组的单元格的情况下,其搜索可能需要一些额外步骤,因为它需要在包含多个值的数组内进行线性搜索。如果我们所有的数据都最终存储在哈希表的单个单元格中,那么我们的哈希表将不比数组好。因此,实际上哈希表查找的最坏情况性能是 O(N)

因此,关键是设计哈希表以尽量减少冲突,因此通常能在 O(1) 时间内进行查找,而不是 O(N) 时间。

幸运的是,大多数编程语言都实现了哈希表,并为我们处理了这些细节。然而,通过了解其内部工作原理,我们能够欣赏到哈希表是如何实现 O(1) 性能的。

让我们看看如何设置哈希表以避免频繁的冲突。

构建高效的哈希表

终归,哈希表的效率取决于三个因素:

• 我们在哈希表中存储的数据量
• 哈希表中可用的单元格数量
• 我们使用的哈希函数

前两个因素为何重要是很好理解的。如果数据量很大,而单元格数量很少,将会有很多冲突,哈希表的效率将会降低。不过,让我们探讨一下为何哈希函数本身对效率很重要。

假设我们使用的哈希函数总是产生一个数值,落在 1 到 9 的范围内。一个例子是一个将字母转换为对应数字,并将结果的各个数字相加直到得到个位数的哈希函数。

举个例子:
PUT = 16 + 21 + 20 = 57
因为 57 包含多个数字,哈希函数将 57 分解成 5 + 7:
5 + 7 = 12
12 也包含多个数字,所以它将 12 分解成 1 + 2:
1 + 2 = 3
最终,PUT 的哈希结果是 3。

这种哈希函数的特性是,它总是返回 1 到 9 之间的数字。

让我们回到我们的示例哈希表:
在这里插入图片描述

使用这种哈希函数,即使存在 10 到 16 的单元格,计算机也永远不会使用它们。所有的数据都将被放入单元格 1 到 9 中。

因此,一个好的哈希函数是能够将其数据分布到所有可用单元格中的函数。我们数据分布得越广泛,冲突就会越少。

伟大的平衡之道

学到了哈希表的效率随着冲突次数的减少而提高。理论上来说,避免冲突的最佳方法就是拥有单元格数量较多的哈希表。想象一下,我们只想在哈希表中存储五个项。有 1,000 个单元格的哈希表对我们来说似乎是很好的选择,因为很有可能不会发生冲突。

然而,尽管避免冲突很重要,但我们也必须平衡考虑避免占用过多内存的问题。虽然有 1,000 个单元格的哈希表对五个数据来说避免冲突效果很好,但我们却使用了 1,000 个单元格来存储仅仅五个数据,这是对内存的极不经济的利用。

这是哈希表必须进行的平衡考量。一个好的哈希表在避免冲突的同时也不会占用大量内存。为了实现这一点,计算机科学家制定了以下经验法则:对于哈希表中存储的每 7 个数据元素,应该有 10 个单元格。所以,如果你打算存储 14 个元素,你就需要有 20 个可用单元格,依此类推。

数据与单元格的这种比例称为负载因子。用这个术语来说,我们会说理想的负载因子是 0.7(7 个元素 / 10 个单元格)。

如果你最初在哈希表中存储了 7 个数据,计算机可能会分配一个有 10 个单元格的哈希表。但当你开始添加更多数据时,计算机会通过添加更多单元格并改变哈希函数的方式来扩展哈希表,以便新的数据能均匀分布在新的单元格中。

再次强调,哈希表的大部分内部工作都由你所使用的计算机语言管理。它决定哈希表需要多大,使用什么样的哈希函数,以及何时扩展哈希表。你可以合理地假设你所使用的编程语言已经实现了哈希表以实现最佳性能。

现在我们已经了解了哈希的工作原理,很明显它们具有 O(1) 的出色查找效率。我们将很快利用这些知识来优化我们的代码以提高速度。
但首先,让我们快速浏览一下哈希表在简单数据组织方面的许多不同用例。

哈希表用于组织

因为哈希表将数据配对存储,所以它们在许多情况下都非常有用来组织数据。

某些数据本身就是以成对形式存在的。本章中的快餐菜单和词库情景就是典型例子。菜单包含每种食物与其价格的配对。词库包含每个单词与其同义词的配对。实际上,在 Python 中,哈希表被称为字典,因为字典是一种常见的配对数据形式:它是一个带有其各自定义的单词列表。

其他自然成对数据的例子可以包括选举统计,如政治候选人和每个候选人获得的选票数:

{"Candidate A" => 1402021, "Candidate B" => 2321443, "Candidate C" => 432}

还有库存跟踪系统,它记录每种物品的库存量:

{"Yellow Shirt" => 1203, "Blue Jeans" => 598, "Green Felt Hat" => 65}

哈希表非常适合处理成对数据,以至于在某些情况下我们甚至可以使用它们来简化条件逻辑。

假设我们遇到一个函数,它返回常见的 HTTP 状态码数字的含义:

def status_code_meaning(number)
    if number == 200
        return "OK"
    elsif number == 301
        return "Moved Permanently"
    elsif number == 401
        return "Unauthorized"
    elsif number == 404
        return "Not Found"
    elsif number == 500
        return "Internal Server Error"
    end
end

如果我们思考一下这段代码,我们会意识到条件逻辑围绕着成对数据展开,即状态码数字及其各自的含义。

通过使用哈希表,我们可以完全消除条件逻辑:

STATUS_CODES = {200 => "OK", 301 => "Moved Permanently",
                401 => "Unauthorized", 404 => "Not Found",
                500 => "Internal Server Error"}

def status_code_meaning(number)
    return STATUS_CODES[number]
end

哈希表的另一个常见用途是表示具有各种属性的对象。例如,这是一只狗的属性表达:

{"Name" => "Fido", "Breed" => "Pug", "Age" => 3, "Gender" => "Male"}

正如你所看到的,属性是一种成对数据,因为属性名成为键,而实际属性成为值。

如果我们在数组中放置多个哈希表,就可以创建一整个狗列表:

[
    {"Name" => "Fido", "Breed" => "Pug", "Age" => 3, "Gender" => "Male"},
    {"Name" => "Lady", "Breed" => "Poodle", "Age" => 6, "Gender" => "Female"},
    {"Name" => "Spot", "Breed" => "Dalmatian", "Age" => 2, "Gender" => "Male"}
]

哈希表用于提速

虽然哈希表非常适用于成对数据,但它们也可以用于提高代码速度——即使你的数据并不是成对存在。这就是真正激动人心的地方。

这是一个简单的数组:

array = [61, 30, 91, 11, 54, 38, 72]

如果你想在这个数组中查找一个数字,需要多少步呢?
因为数组是无序的,你将需要执行线性搜索,这将花费 N 步。你在本书开头学到了这一点。

但是,如果我们运行一些代码,将这些数字转换成如下所示的哈希表,会发生什么呢?

hash_table = {61 => true, 30 => true, 91 => true, 11 => true, 54 => true, 38 => true, 72 => true}

在这里,我们将每个数字存储为一个键,并将布尔值 true 分配为每个数字的关联值。

现在,如果我让你在这个哈希表中搜索特定的数字作为键,会花费多少步呢?
很好,使用简单的代码:

hash_table[72]

我可以在一个步骤中查找数字 72。

也就是说,通过使用 72 作为键进行哈希表查找,我可以在一个步骤中确定 72 是否存在于哈希表中。推理很简单:如果 72 是哈希表中的一个键,我将得到 true,因为 72 的值为 true。另一方面,如果 72 不是哈希表中的键,我将得到 nil。(不同的编程语言在键不存在于哈希表时返回不同的值。Ruby 返回 nil。)

由于进行哈希表查找只需要一步,因此我们可以在一步内找到哈希表中的任何数字(作为键)。

你能看到这种魔力吗?
通过以这种方式将数组转换为哈希表,我们可以从 O(N) 的搜索转变为 O(1) 的搜索。

使用哈希表的有趣之处在于,尽管哈希表经常用于自然成对数据,但我们这里的数据并不是成对的。我们只关心一个单独数字的列表。

虽然我们为每个键分配了一个值,但值是什么并不重要。我们使用 true 作为每个键的值,但任何任意值(“真值”)都可以达到相同的结果。

诀窍在于,通过将每个数字作为键放入哈希表中,我们稍后可以在一步中查找到这些键中的每一个。如果我们的查找返回任何值,这意味着该键本身必须在哈希表中。如果我们得到 nil,那么这个键必定不在哈希表中。

我把以这种方式使用哈希表称为“使用它作为索引”(这是我自己的术语)。书的后面索引告诉你是否可以在书中找到这个主题,而不是让你翻阅所有页面去找它。在这里,我们创建了哈希表来充当一种索引;在我们的例子中,它是一个索引,告诉我们特定项目是否包含在原始数组中。

让我们使用这种技术来提升一个非常实用的算法的速度。

数组子集

看来我们需要确定一个数组是否是另一个数组的子集。以这两个数组为例:

["a", "b", "c", "d", "e", "f"]
["b", "d", "f"]

第二个数组 ["b", "d", "f"] 是第一个数组 ["a", "b", "c", "d", "e", "f"] 的子集,因为第二个数组的每个值都包含在第一个数组中。

但是,如果我们的数组是:

["a", "b", "c", "d", "e", "f"]
["b", "d", "f", "h"]

第二个数组不是第一个数组的子集,因为第二个数组包含值 “h”,而这个值在第一个数组中不存在。

我们该如何编写一个函数,来比较两个数组并判断其中一个是否是另一个的子集?

一种方法是使用嵌套循环。基本上,我们会遍历较小数组的每个元素,并针对较小数组中的每个元素,启动第二个循环,遍历较大数组的每个元素。如果我们在较小数组中找到一个在较大数组中不存在的元素,函数将返回 false。如果代码通过了这些循环,说明它从未遇到在较小数组中不存在于较大数组中的值,因此返回 true。

以下是这种方法的 JavaScript 实现:

function isSubset(array1, array2) {
  let largerArray;
  let smallerArray;

  // 确定哪个数组更小:
  if (array1.length > array2.length) {
    largerArray = array1;
    smallerArray = array2;
  } else {
    largerArray = array2;
    smallerArray = array1;
  }

  // 遍历较小数组:
  for (let i = 0; i < smallerArray.length; i++) {
    // 暂时假设较小数组中的当前值不在较大数组中:
    let foundMatch = false;

    // 对于较小数组中的每个值,遍历较大数组:
    for (let j = 0; j < largerArray.length; j++) {
      // 如果两个值相等,意味着较小数组中的当前值存在于较大数组中:
      if (smallerArray[i] === largerArray[j]) {
        foundMatch = true;
        break;
      }
    }

    // 如果较小数组中的当前值在较大数组中不存在,返回 false:
    if (foundMatch === false) {
      return false;
    }
  }

  // 如果我们执行到循环结束,说明较小数组中的所有值都存在于较大数组中:
  return true;
}

分析这个算法的效率,它是 O(N * M),因为它的运行次数取决于第一个数组的项数乘以第二个数组的项数。

现在,让我们利用哈希表的威力,大幅提高算法的效率。我们放弃原始的方法,重新从头开始。

在我们的新方法中,确定了哪个数组更大、哪个更小后,我们将对较大数组执行单个循环,并将每个值存储在哈希表中:

let hashTable = {};
for (const value of largerArray) {
  hashTable[value] = true;
}

在这段代码中,我们在 hashTable 变量中创建了一个空的哈希表。然后,我们遍历了 largerArray 中的每个值,并将数组中的项添加到哈希表中。我们将数组项本身作为键,true 作为值。

对于先前的示例 ["a", "b", "c", "d", "e", "f"],运行完这个循环后,我们得到了一个看起来像这样的哈希表:

{"a": true, "b": true, "c": true, "d": true, "e": true, "f": true}

这将成为我们稍后进行 O(1) 查找的“索引”。

现在,这是一个绝妙之处。一旦第一个循环完成,我们有了这个哈希表,我们就可以开始第二个(非嵌套)循环,遍历较小数组:

for (const value of smallerArray) {
  if (!hashTable[value]) {
    return false;
  }
}

这个循环检查较小数组中的每个项目,看它是否作为键存在于 hashTable 中。记住,hashTable 存储了 largerArray 中的所有项作为它的键。所以,如果我们在 hashTable 中找到一个项目,这意味着该项目也在 largerArray 中。如果我们在 hashTable 中找不到一个项目,这意味着该项目也不在 largerArray 中。

因此,对于较小数组中的每个项目,我们检查它是否是 hashTable 中的一个键。如果不是,这意味着较小数组中不包含在较大数组中的该项目,于是我们返回 false。(然而,如果我们通过了这个循环,这意味着较小数组是较大数组的子集。)

让我们将所有内容整合到一个完整的函数中:

function isSubset(array1, array2) {
  let largerArray;
  let smallerArray;
  let hashTable = {};

  // 确定哪个数组更小:
  if (array1.length > array2.length) {
    largerArray = array1;
    smallerArray = array2;
  } else {
    largerArray = array2;
    smallerArray = array1;
  }

  // 将较大数组的所有项存储在哈希表中:
  for (const value of largerArray) {
    hashTable[value] = true;
  }

  // 遍历较小数组中的每个项目,如果遇到不在哈希表中的项目则返回 false:
  for (const value of smallerArray) {
    if (!hashTable[value]) {
      return false;
    }
  }

  // 如果我们在代码中执行到这里而没有返回 false,这意味着较小数组中的所有项目必定包含在较大数组中:
  return true;
}

现在,这个算法花费了多少步骤?我们只需遍历一次较大数组以构建哈希表。接着对较小数组中的每个项目进行迭代,每个项目只需进行一次哈希表查找。记住,哈希表查找只需一步。

假设 N 是两个数组合并后的总项数,我们的算法是 O(N),因为我们只处理了每个项一次。也就是说,我们在较大数组的每个项上花费了一步,然后在较小数组的每个项上也只花费了一步。

这相对于我们之前的算法,即 O(N * M),是一个巨大的进步。

在需要在数组中进行多次搜索的算法中,这种使用哈希表作为“索引”的技术经常出现。这就是说,如果您的算法需要在一组数据中进行多次查找,则可以将这组数据转换为哈希表,以便以 O(1) 的效率进行搜索,而不是 O(N) 或更高的效率。计算一些东西时,这种技术特别有用。比如,找出两个数组中的相同元素、查找数组中是否存在重复项或确定一个数组是否是另一个数组的子集,这些情况都可以使用哈希表来提高效率。

所以,如果您需要在算法中频繁地进行查找或对比两个数组,将数组转换为哈希表可能是个好主意,这样可以大幅提高效率。哈希表作为一种高效的数据结构,可以帮助您快速解决许多搜索和查找问题。

在编写代码时,记得在选择使用哈希表时权衡一下内存消耗和性能。哈希表需要一些额外的内存来存储键值对,所以在考虑使用它们时要注意内存限制。

总的来说,哈希表是一种强大而高效的工具,能够以 O(1) 的时间复杂度进行搜索和查找,这在处理大量数据和优化算法中非常有用。

总结

哈希表在构建高效软件时是不可或缺的。凭借其 O(1) 的读取和插入操作,它是一种难以超越的数据结构。

直到现在,我们对各种数据结构的分析都围绕着它们的效率和速度。但您知道有些数据结构除了速度之外还提供其他优势吗?在下一课中,我们将探讨两种可以帮助提高代码优雅性和可维护性的数据结构。

练习

  1. 编写一个函数,返回两个数组的交集。交集是一个包含在前两个数组中的所有值的第三个数组。例如,[1, 2, 3, 4, 5] 和 [0, 2, 4, 6, 8] 的交集是 [2, 4]。您的函数应具有 O(N) 的时间复杂度。(如果您的编程语言有内置方法可以实现,请不要使用。这个想法是自己构建算法。)

  2. 编写一个接受字符串数组的函数,并返回它发现的第一个重复值。例如,如果数组是 [“a”, “b”, “c”, “d”, “c”, “e”, “f”],函数应返回 “c”,因为它在数组中重复出现一次。 (您可以假设数组中有一对重复项。)确保函数的效率为 O(N)。

  3. 编写一个接受包含除一个字母以外的所有字母的字符串的函数,并返回缺失的字母。例如,字符串 “the quick brown box jumps over a lazy dog” 包含字母表中除了字母 “f” 之外的所有字母。该函数的时间复杂度应为 O(N)。

  4. 编写一个函数,返回字符串中第一个不重复的字符。例如,字符串 “minimum” 中有两个仅出现一次的字符——“n” 和 “u”,因此您的函数应返回 “n”,因为它首先出现。函数应具有 O(N) 的效率。

答案

  1. 下面的实现首先将第一个数组的值存储在一个哈希表中,然后检查第二个数组的每个值是否存在于该哈希表中:
function getIntersection(array1, array2) {
    let intersection = [];
    let hashTable = {};
    for (let i = 0; i < array1.length; i++) {
        hashTable[array1[i]] = true;
    }
    for (let j = 0; j < array2.length; j++) {
        if (hashTable[array2[j]]) {
            intersection.push(array2[j]);
        }
    }
    return intersection;
}

该算法的效率为 O(N)。

  1. 下面的实现检查数组中的每个字符串。如果字符串尚未在哈希表中,则将其添加。如果字符串已在哈希表中,这意味着它已经被添加过,这意味着它是一个重复项!该算法的时间复杂度为 O(N):
function findDuplicate(array) {
    let hashTable = {};
    for (let i = 0; i < array.length; i++) {
        if (hashTable[array[i]]) {
            return array[i];
        } else {
            hashTable[array[i]] = true;
        }
    }
}
  1. 下面的实现首先通过遇到的字符创建一个哈希表。接下来,我们遍历字母表中的每个字符,并检查该字符是否包含在我们的哈希表中。如果没有,这意味着该字符在字符串中缺失,因此我们返回它:
function findMissingLetter(string) {
    // 存储所有遇到的字母在哈希表中:
    let hashTable = {};
    for (let i = 0; i < string.length; i++) {
        hashTable[string[i]] = true;
    }
    // 返回第一个未见过的字母:
    let alphabet = "abcdefghijklmnopqrstuvwxyz";
    for (let i = 0; i < alphabet.length; i++) {
        if (!hashTable[alphabet[i]]) {
            return alphabet[i];
        }
    }
}
  1. 下面的实现首先遍历字符串中的每个字符。如果字符尚不存在于哈希表中,则将该字符作为键添加到哈希表中,其值为 1,表示到目前为止找到该字符一次。如果字符已存在于哈希表中,则简单地将值递增 1。因此,如果字符 “e” 的值为 3,则意味着字符串中存在三个 “e”。
    然后,我们再次遍历字符,并返回字符串中仅出现一次的第一个字符。该算法为 O(N):
function firstNonDuplicate(string) {
    let hashTable = {};
    for (let i = 0; i < string.length; i++) {
        if (hashTable[string[i]]) {
            hashTable[string[i]]++;
        } else {
            hashTable[string[i]] = 1;
        }
    }
    for (let j = 0; j < string.length; j++) {
        if (hashTable[string[j]] == 1) {
            return string[j];
        }
    }
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值