哈希表真的可以是O(1)吗?


114

哈希表可以实现O(1)似乎是常识,但是这对我来说从来没有任何意义。有人可以解释一下吗?这是两种情况:

答: 该值是一个小于哈希表大小的整数。因此,该值是其自己的哈希,因此没有哈希表。但是,如果有的话,它将是O(1),但效率仍然很低。

B. 您必须计算值的哈希值。在这种情况下,对于要查找的数据大小,顺序为O(n)。在您完成O(n)工作后,查找可能是O(1),但在我眼里仍然是O(n)。

而且,除非您拥有完美的哈希表或大型哈希表,否则每个存储桶中可能有几项。因此,无论如何它会演变成小的线性搜索。

我认为哈希表很棒,但除非得到理论上的支持,否则我不会获得O(1)的名称。

Wikipedia的有关哈希表文章始终引用恒定的查找时间,并且完全忽略了哈希函数的成本。这真的是公平的措施吗?


编辑:总结一下我学到的东西:

  • 从技术上讲,这是正确的,因为不需要散列函数使用键中的所有信息,因此可以是恒定时间,并且因为足够大的表可以使冲突降低到接近恒定时间。

  • 在实践中确实如此,因为随着时间的推移,只要选择哈希函数和表大小以最大程度地减少冲突,就可以解决问题,尽管这通常意味着不使用恒定时间哈希函数。


31
摊销O(1),而不是O(1)。
kennytm'5

请记住,O()是大量操作的限制。平均而言,您不会有很多冲突-单个操作没有冲突是没有必要的。
马丁·贝克特

根据字符串的实现,字符串可能随身携带其哈希值,因此这将是恒定的。关键是,它与哈希查找的复杂性无关。
Rich Remer

@kennytm当然,对输入进行哈希处理后的查找摊销O(1)。但是,计算哈希的成本真的可以忽略不计吗?假设我们正在对一个字符串进行哈希处理–一个字符数组。为了生成哈希,将迭代每个字符,因此哈希字符串为O(N),其中N是字符串的长度。这就是C#的文档记录方式,这也是Java的hashCode()方法实现方式Stringgrepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/...
spaaarky21

1
@ spaaarky21您正在谈论的O(N)中的N是字符串的长度,与哈希表的大小n不同。马克·拜尔(Mark Byer)的答案已经解决了这一问题。
kennytm '17

Answers:


65

这里有两个变量,m和n,其中m是输入的长度,n是哈希中的项目数。

O(1)查询性能声明至少做出两个假设:

  • 您的对象在O(1)时间内可以相等。
  • 几乎没有哈希冲突。

如果对象的大小可变,并且相等性检查需要查看所有位,则性能将变为O(m)。但是,哈希函数不必为O(m)-可以为O(1)。与密码哈希不同,在字典中使用的哈希函数不必查看输入中的每一位即可计算哈希。实现可以随意查看固定位数。

对于足够多的项目,项目数将变得大于可能的散列数,然后您将发生碰撞,从而导致性能提高到O(1)以上,例如对于简单链表遍历(或O(n * m)如果两个假设都为假)。

在实践中,尽管O(1)声称在技术上是错误的,但在许多现实情况下,尤其是在上述假设成立的情况下,大约是正确的。


4
和上面一样,如果您使用不可变对象作为键(例如Java字符串),则仅计算一次哈希值,就可以记住它,而不必再次计算。另一方面,一旦找到正确的存储桶,通常就不能依靠哈希来判断两个键是否相等,因此对于字符串,您需要进行O(m)遍历以找出它们是否相等。
JeremyP,2010年

1
@JeremyP:关于O(m)相等比较的要点。我错过了-更新帖子。谢谢!
Mark Byers

2
O(1)如果您对ints或适用于机器字的其他内容进行哈希处理,则该声明为true 。这就是大多数散列理论所假设的。
托马斯·阿勒

我喜欢您Mark的解释,我在我的有关meshfields.de/hash-tables
Steve K

3
“ m是输入的长度”中 - 输入过于含糊-可能意味着要插入所有键和值,但稍后(至少对于已经了解该主题的人来说)就清楚了,您指的是。只是为了清楚起见,建议在答案中使用“键”。顺便说一句-具体示例-Visual C ++ std::hash的文本键将沿文本均匀分布的10个字符组合为哈希值,因此无论文本长度如何,它均为O(1)(但比GCC容易发生碰撞!)。另外,O(1)的声明有另一个假设(通常是正确的),即m远小于n
Tony Delroy

22

您必须计算哈希,因此对于要查找的数据大小,顺序为O(n)。在您执行O(n)工作后,查找可能是O(1),但在我眼中仍然是O(n)。

什么?散列单个元素需要固定的时间。为什么还会是其他呢?如果您要插入n元素,则是的,您必须计算n散列,并且这需要线性的时间...要查找元素,您可以计算出所要查找的单个散列,然后使用该散列找到合适的存储桶。您无需重新计算哈希表中所有内容的哈希值。

而且,除非您拥有完美的哈希表或大型哈希表,否则每个存储桶中可能有多个项目,因此无论如何它都会演变为小型线性搜索。

不必要。存储桶不一定必须是列表或数组,它们可以是任何容器类型,例如平衡的BST。那意味着O(log n)最坏的情况。但这就是为什么选择一个良好的哈希函数以避免将太多元素放入一个存储桶中很重要的原因。正如KennyTM指出的那样,O(1)即使偶尔需要挖一个桶,平均而言,您仍然会得到时间。

哈希表的权衡当然是空间的复杂性。您在争取时间,这似乎是计算机科学中的常见情况。


您在其他评论之一中提到使用字符串作为键。您担心计算字符串的哈希值需要花费多少时间,因为它包含几个字符?就像其他人再次指出的那样,您不一定需要查看所有字符来计算哈希,尽管如果这样做可能会产生更好的哈希。在这种情况下,如果m您的密钥中平均有char,并且您全部使用了char来计算哈希,那么我想您是对的,那么查找就可以了O(m)。如果那样的m >> n话,您可能有问题。在这种情况下,使用BST可能会更好。或者选择更便宜的哈希函数。


哈希表不使用BST。BST不需要哈希值。地图和集合可以实现为BST。
Nick Dandoulakis 2010年

3
@尼克:嗯?不... BST不需要哈希值...这就是重点。我们假设在这一点上我们已经发生了冲突(相同的哈希值或至少相同的存储桶),因此我们需要查看其他内容以找到正确的元素,即实际值。
mpen 2010年

哦,我明白你的意思了。但是我不确定混合使用BST和哈希值是否值得解决。为什么不只使用BST?
尼克·丹杜拉基斯

2
我只是说,您可以摆脱O(n)碰撞。如果你正在期待大量的碰撞,那么你是对的,可能会更好过与在首位BST去。
mpen 2010年

1
@ spaaarky21是的,但是N在这种情况下是字符串的长度。我们只需要对一个字符串进行哈希处理就可以确定它需要进入哪个“存储桶”了-它不会随着哈希图的长度而增长。
mpen

5

哈希是固定大小的-查找适当的哈希存储区是固定成本的操作。这意味着它是O(1)。

计算散列不一定是一项特别昂贵的操作-我们在这里不讨论加密散列函数。但这是顺带一提。哈希函数的计算本身并不取决于元素的数量n;尽管它可能取决于元素中数据的大小,但这不是n所指的。因此,哈希的计算不依赖于n,并且也是O(1)。


3
查找哈希桶的值为O(1)。但是定位右键是一个O(n)过程,其中n取决于哈希冲突数。
Nick Dandoulakis 2010年

1
那么3个步骤,计算散列,找到存储桶,搜索存储桶,中间步骤是恒定的吗?搜索存储桶通常是恒定的。计算散列通常比其他查找存储区的方法便宜几个数量级。但这真的等于固定时间吗?在朴素的子字符串搜索中,对于两个长度,您会说O(n * m),那么为什么这里忽略了键的长度?
2010年

只有在列表支持的情况下,找到固定长度的密钥才是O(n),平衡树支持的哈希表将是O(log(n))
jk。

@Jk对于良好的哈希函数,最坏的情况总是logn,请参见stackoverflow.com/questions/4553624/hashmap-get-put-complexity/…的
Thomas Ahle 2014年

在最坏的情况下,如果发生碰撞,复杂度将为o(n)
Saurabh Chandra Patel

3

仅当表中的键数恒定且进行了其他一些假设时,哈希才为O(1)。但是在这种情况下它具有优势。

如果您的密钥具有n位表示形式,则您的哈希函数可以使用这些位中的1、2,... n。考虑使用1位的哈希函数。评估肯定是O(1)。但是,您仅将密钥空间划分为2。因此,您会将多达2 ^(n-1)个密钥映射到同一容器中。使用BST搜索,如果某个特定密钥几乎已满,则最多需要n-1个步骤来找到它。

您可以对此进行扩展,以查看如果您的哈希函数使用K位,则bin大小为2 ^(nk)。

因此,K位哈希函数==>不超过2 ^ K个有效垃圾箱==>每个垃圾箱最多2 ^(nK)个n位密钥==>(nK)个步骤(BST)解决冲突。实际上,大多数散列函数的“有效性”要差得多,并且需要/使用多于K位才能生成2 ^ k个bin。因此,即使这是乐观的。

您可以通过这种方式查看它-在最坏的情况下,您将需要〜n个步骤才能唯一区分n位密钥对。实际上,没有办法解决这个信息理论的限制,无论哈希表是否存在。

但是,这不是/何时使用哈希表!

复杂度分析假设对于n位密钥,表中可以有O(2 ^ n)个密钥(例如,所有可能的密钥的1/4)。但是在大多数情况下(即使不是全部),我们使用哈希表时,表中只有恒定数量的n位密钥。如果您只希望表中有恒定数量的键,例如C是最大数量,则可以形成O(C)个bin的哈希表,以保证预期的恒定冲突(具有良好的哈希函数);以及使用密钥中n位的〜logC的哈希函数。那么每个查询都是O(logC)= O(1)。人们就是这样宣称“哈希表访问为O(1)” /

这里有两个问题-首先,说您不需要所有比特可能只是一个计费技巧。首先,您不能真正将键值传递给哈希函数,因为那样会在内存中移动n位O(n)。因此,您需要执行例如引用传递。但是您仍然需要将其存储在已经是O(n)操作的某个位置;您只是不将其计入哈希;您的总体计算任务无法避免这一点。其次,进行哈希处理,找到bin,并找到1个以上的密钥;您的成本取决于您的解决方法-如果您基于比较(BST或列表),将进行O(n)操作(调用键为n位);如果您进行第二次哈希处理,那么,如果第二次哈希处理发生冲突,则会遇到相同的问题。

在这种情况下,请考虑替代方法,例如BST。有C键,因此平衡的BST深度为O(logC),因此搜索需要O(logC)步骤。但是,在这种情况下的比较将是O(n)操作……因此,在这种情况下,哈希似乎是一个更好的选择。


1

TL; DR:O(1)如果从通用哈希函数系列中随机选择哈希函数,哈希表可保证预期的最坏情况时间。预期的最坏情况与平均情况不同。

免责声明:我没有正式证O(1)明哈希表是,因为请看Coursera的这段视频[ 1 ]。我也不讨论摊销哈希表方面。这与关于散列和冲突的讨论正交。

在其他答案和评论中,我对此主题感到非常令人困惑,并且将在此较长的答案中尝试纠正其中的一些问题。

关于最坏情况的推理

有不同类型的最坏情况分析。到目前为止,大多数答案在这里所做的分析不是最坏的情况,而是平均情况 [ 2 ]。平均案例分析往往更实用。也许您的算法有一个糟糕的最坏情况输入,但实际上对所有其他可能的输入都适用。底线是您的运行时取决于数据集您正在运行。

考虑以下get哈希表方法的伪代码。在这里,我假设我们通过链接来处理冲突,因此表的每个条目都是(key,value)成对的链接列表。我们还假设存储桶的数量m是固定的,但是是O(n),其中n输入中的元素数量是。

function get(a: Table with m buckets, k: Key being looked up)
  bucket <- compute hash(k) modulo m
  for each (key,value) in a[bucket]
    return value if k == key
  return not_found

正如其他答案所指出的那样,这是在平均O(1)和最坏的情况下进行的O(n)。我们可以在此处通过挑战略述证明。挑战如下:

(1)您将哈希表算法交给对手。

(2)对手可以根据需要进行学习和准备。

(3)最后,对手会为您提供一个大小输入,n供您插入表格中。

问题是:您的哈希表在对手输入上的速度有多快?

从步骤(1),对手知道您的哈希函数;在步骤(2)中,对手可以通过例如随机计算一堆元素的哈希来制作n具有相同元素的列表hash modulo m。然后在(3)中他们可以给您该列表。但是请注意,由于所有n元素都散列到同一存储桶中,因此您的算法将需要O(n)时间来遍历该存储桶中的链表。无论我们重试挑战多少次,对手总是会获胜,这就是最坏情况下您的算法有多糟糕O(n)

O(1)为何是哈希?

在上一个挑战中使我们脱颖而出的是,对手非常了解我们的哈希函数,并可以利用该知识来编写最差的输入。如果实际上有一组哈希函数而不是总是使用一个固定的哈希函数,H该算法可以在运行时从中随机选择,该怎么办?如果您好奇的话,它H被称为哈希函数通用家族 [ 3 ]。好吧,让我们尝试为此添加一些随机性

首先,假设我们的哈希表还包含一个seed r,并且r在构造时被分配给一个随机数。我们分配一次,然后针对该哈希表实例进行固定。现在,让我们重新访问我们的伪代码。

function get(a: Table with m buckets and seed r, k: Key being looked up)
  rHash <- H[r]
  bucket <- compute rHash(k) modulo m
  for each (key,value) in a[bucket]
    return value if k == key
  return not_found

如果我们再尝试一次挑战:从步骤(1)开始,对手可以知道我们拥有的所有哈希函数H,但是现在我们使用的特定哈希函数取决于r。的值r对我们的结构是私有的,对手无法在运行时对其进行检查,也无法提前进行预测,因此他无法编制一份对我们始终不利的清单。让我们假设,在步骤(2)对手选择一个功能,hashH随机的,然后他工艺品的列表n下的碰撞hash modulo m,并发送步骤(3),穿越手指在运行时H[r]将是相同的hash,他们选择。

这对对手来说是一个很大的赌注,他精心制作的列表会与该列表发生冲突hash,但是在中的任何其他哈希函数下,它只是一个随机输入H。如果他赢了这个赌注,我们的运行时间将是最糟糕的情况O(n),但是如果他输了,那么我们将获得随机输入,这需要平均O(1)时间。实际上,在大多数情况下,对手会失败,他在每次|H|挑战中只赢一次,所以我们可以做到|H|很大。

将该结果与先前的算法进行对比,在先前的算法中,对手总是赢得挑战。有点费力,但是由于大多数情况下对手会失败,并且对手可能尝试的所有可能策略都是如此,因此可以得出结论,尽管最坏的情况是O(n),但实际上预期的最坏的情况O(1)


同样,这不是正式证明。从这种预期的最坏情况分析中得到的保证是,我们的运行时间现在独立于任何特定的输入。这是一个真正的随机保证,与平均案例分析相反,在平均案例分析中,我们发现有动机的对手很容易做出错误的输入。


0

有两种设置可让您获得O(1)最坏的情况。

  1. 如果您的设置是静态的,则FKS哈希将为您提供最坏的O(1)保证。但正如您指出的那样,您的设置不是静态的。
  2. 如果使用杜鹃哈希,则查询和删除是O(1) 最坏的情况,但插入仅是O(1)。如果您对插入的总数有上限,并且将表大小设置为大约大25%,则杜鹃哈希效果很好。

这里复制


0

似乎基于此处的讨论,如果X是(表中元素的数量/箱的数量)的上限,则假定箱查找的有效实现,更好的答案是O(log(X))。


0

答:该值是一个小于哈希表大小的整数。因此,该值是其自己的哈希,因此没有哈希表。但是,如果有的话,它将是O(1),但效率仍然很低。

在这种情况下,您可以将键琐细地映射到不同的存储桶,因此与散列表相比,数组似乎是数据结构更好的选择。尽管如此,效率低下并不会随着表的大小而增加。

(您可能仍会使用哈希表,因为您不相信int会随着程序的发展而保持小于表的大小,您想要使代码在这种关系不成立时可能可重用,或者只是不这样做而已。希望人们阅读/维护代码不得不浪费精力来理解和维护关系。

B.您必须计算值的哈希值。在这种情况下,对于要查找的数据大小,顺序为O(n)。在您执行O(n)工作后,查找可能是O(1),但在我眼中仍然是O(n)。

我们需要区分密钥的大小(例如,以字节为单位)和哈希表中存储的密钥数量的大小。声称哈希表提供O(1)操作意味着随着键的数量从数百增加到数千到数百万到数十亿,操作(插入/擦除/查找)不会趋于进一步减速(至少在所有数据都没有的情况下)可以在同样快速的存储中访问/更新,无论是RAM还是磁盘-缓存效果都可能发挥作用,但即使是最坏情况的缓存未命中的代价也往往是最佳情况命中率的恒定倍数。

考虑一本电话簿:您那里的名字可能很长,但是无论该书有100个名字还是1000万个名字,平均名字长度都将保持一致,这是历史上最糟糕的情况...

Adolph Blaine查尔斯·戴维·厄尔·弗雷德里克·杰拉尔德·休伯特·艾尔文·约翰·肯尼思·劳埃德·马丁·尼罗·奥利弗·保罗·昆西·兰道夫·谢尔曼·托马斯·安卡斯·维克托·威廉·谢尔克斯·杨西·沃尔德·施莱格斯坦

...... wc告诉我这是215个字符-这不是一个上限密钥长度,但我们并不需要担心那里是大规模多。

这适用于大多数现实世界的哈希表:平均密钥长度不会随着使用的密钥数量而增加。有例外,例如,密钥创建例程可能返回嵌入递增整数的字符串,但是即使如此,每次将密钥数目增加一个数量级时,密钥长度也只会增加1个字符:这并不重要。

也可以根据固定大小的键数据创建哈希。例如,Microsoft的Visual C ++附带了标准库实现,std::hash<std::string>该实现创建了一个散列,该散列仅包含沿字符串均匀间隔的十个字节,因此,如果字符串仅在其他索引处发生变化,则会发生冲突(因此,实际上是非O(1)行为)在冲突后搜索方面),但是创建哈希的时间有一个硬上限。

而且,除非您拥有完美的哈希表或大型哈希表,否则每个存储桶中可能有几项。因此,无论如何它会演变成小的线性搜索。

通常是正确的,但是关于哈希表的很棒的事情是,在那些“小的线性搜索”期间访问的键的数量是-对于冲突的单独链接方法-哈希表负载因子(键与存储桶的比率)的函数。

例如,负载因子为1.0时,这些线性搜索的长度平均为〜1.58,而与键的数量无关(请参见此处的答案)。对于封闭式散列来说,它有点复杂,但是在负载因数不太高的情况下也不会更糟。

从技术上讲,这是正确的,因为不需要散列函数使用键中的所有信息,因此可以是恒定时间,并且因为足够大的表可以使冲突降低到接近恒定时间。

这种错点了。最终,任何类型的关联数据结构最终都必须在键的每个部分上进行操作(有时可能仅从键的一部分确定不平等,但通常需要考虑到平等性)。至少,它可以对密钥进行一次哈希处理并存储哈希值,如果它使用足够强大的哈希函数(例如64位MD5),则实际上甚至可以忽略两个密钥哈希为相同值的可能性(一家公司我为之工作的正是分布式数据库:哈希生成时间与WAN范围内的网络传输相比仍然微不足道。因此,对于处理密钥的成本并没有太多的困扰:不管数据结构如何,密钥的存储都是固有的,如上所述。

至于足够大的哈希表可以减少冲突,那也没有抓住重点。对于单独的链接,在任何给定的负载系数下,您仍然具有恒定的平均碰撞链长度-当负载系数较高时,该长度才刚长,并且这种关系是非线性的。SO用户Hans对我的回答的评论也链接在上面

以非空桶为条件的平均桶长是衡量效率的更好方法。它是a /(1-e ^ {-a})[其中a是负载系数,e是2.71828 ...]

因此,负载因子确定在插入/擦除/查找操作期间必须搜索的冲突键的平均数量。对于单独的链接,当负载系数很低时,它不仅接近恒定,而且始终恒定。对于开放式寻址,尽管您的主张具有一定的有效性:某些冲突元素将重定向到备用存储桶,然后可能会干扰其他键的操作,因此,在较高的负载因子(尤其是> 0.8或.9)下,碰撞链长度会变得更加糟糕。

在实践中确实如此,因为随着时间的推移,只要选择哈希函数和表大小以最大程度地减少冲突,就可以解决问题,尽管这通常意味着不使用恒定时间哈希函数。

好吧,鉴于选择紧密散列或单独链接,表大小应导致合理的负载因子,但是如果散列函数有点弱并且键不是非常随机的话,那么拥有大量存储桶通常有助于减少也会发生冲突(hash-value % table-size然后回绕,这样哈希值中仅对高阶位或两位进行更改仍会解析为在哈希表的不同部分伪随机分布的存储桶)。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.