怎么了O(1)?

时间:2008-12-02 03:29:44

标签: search collections complexity-theory data-access

在讨论涉及散列和搜索类型的算法时,我一直注意到O(1)的一些非常奇怪的用法,通常是在使用语言系统提供的字典类型或使用字典或散列数组类型的上下文中使用数组索引表示法。

基本上,O(1)意味着以恒定时间和(通常)固定空间为界。一些非常基本的操作是O(1),尽管使用中间语言和特殊虚拟机往往会扭曲想到这里的人(例如,如何将垃圾收集器和其他动态过程分摊到原来的O(1)活动上)。 / p>

但是忽略了延迟,垃圾收集等的摊销,我仍然不明白如何假设某些涉及某种搜索的技术可以是O(1),除非在非常特殊的条件下。 / p>

虽然我之前已经注意到这一点,但是一个例子只出现在Pandincus question, "'Proper’ collection to use to obtain items in O(1) time in C# .NET?"

正如我在那里所说的那样,我所知道的唯一一个提供O(1)访问作为保证边界的集合是一个带有整数索引值的固定绑定数组。假设数组是通过一些映射到随机存取存储器来实现的,该存储器使用O(1)操作来定位具有该索引的单元。

对于涉及某种搜索以确定不同类型索引(或具有整数索引的稀疏数组)的匹配单元的位置的集合,生活并不那么容易。特别是,如果存在可能的碰撞和拥堵,则访问不完全是O(1)。如果集合是灵活的,那么必须识别并分摊扩展底层结构(例如树或哈希表)的成本,以便 拥塞缓解(例如,高冲突发生率或树木不平衡)

我永远不会想到将这些灵活和动态的结构称为O(1)。然而,我看到它们作为O(1)解决方案被提供,而没有任何必须保持​​的条件,以确保实际上具有O(1)访问(并且使该常数可以忽略不计)。

问题:所有这些准备都是一个问题。 O(1)的偶然性是什么?为什么这么盲目地被接受?是否认识到即使O(1)可能不合需要地大,即使接近常数?或者O(1)只是将计算复杂性概念挪用于非正式用途?我很困惑。

更新:答案和评论指出了我自己定义O(1)的习惯,我已经修复了。我仍然在寻找好的答案,在一些情况下,一些评论主题比他们的答案更有趣。

13 个答案:

答案 0 :(得分:61)

问题在于人们对术语非常草率。这里有3个重要但不同的类别:

O(1)最坏情况

这很简单 - 在最坏的情况下,所有操作只需要一个恒定的时间,因此在所有情况下都是如此。访问数组的元素是O(1)最坏情况。

O(1)摊销最坏情况

分摊意味着在最坏的情况下并非每个操作都是O(1),但对于N个操作的任何序列,序列的总成本不是O(N)。最糟糕的情况。这意味着即使我们不能通过常量限制任何单个操作的成本,总会有足够的“快速”操作来弥补“慢”操作,使得操作序列的运行时间是线性的在操作次数。

例如,标准Dynamic Array在填满时将其容量加倍,需要O(1)分摊时间才能在末尾插入元素,即使某些插入需要O(N)时间 - 那里总是O(1)插入,插入N个项目总是需要O(N)个时间。

O(1)平均情况

这个是最棘手的。平均情况有两种可能的定义:一种是固定输入的随机算法,另一种是随机输入的确定性算法。

对于具有固定输入的随机算法,我们可以通过分析算法并确定所有可能的运行时间的概率分布并计算该分布的平均值来计算任何给定输入的平均情况运行时间(取决于算法,由于停机问题,这可能会或可能不会发生。

在另一种情况下,我们需要输入的概率分布。例如,如果我们要测量排序算法,那么一个这样的概率分布将是具有全N的分布!输入的可能排列同样可能。然后,平均情况运行时间是所有可能输入的平均运行时间,由每个输入的概率加权。

由于这个问题的主题是确定性的哈希表,我将重点关注平均情况的第二个定义。现在,我们不能总是确定输入的概率分布,因为我们可以对任何事物进行散列,这些项可能来自用户在文件系统中输入或从文件系统中输入。因此,在谈论哈希表时,大多数人只是假设输入表现良好并且哈希函数表现良好,使得任何输入的哈希值基本上在可能的哈希值范围内均匀地随机分布。

花点时间让最后一点陷入其中 - 哈希表的O(1)平均值表现来自假设所有哈希值均匀分布。如果这个假设被违反(通常不是这样,但肯定可以而且确实发生),平均运行时间不再是O(1)

另见Denial of Service by Algorithmic Complexity。在本文中,作者讨论了他们如何利用两个Perl版本使用的默认哈希函数中的一些弱点来生成大量具有哈希冲突的字符串。有了这个字符串列表,他们通过向这些字符串服务器提供这些字符串,导致Web服务器使用的哈希表中出现最坏情况O(N)行为,从而对某些Web服务器产生了拒绝服务攻击。

答案 1 :(得分:40)

我的理解是O(1)不一定是恒定的;相反,它不依赖于所考虑的变量。因此,哈希查找可以说是关于哈希中元素数量的O(1),但不是关于散列数据的长度或散列中元素与桶的比率。

混淆的另一个原因是大O符号描述了限制行为。因此,对于小N值的函数f(N)可能确实显示出很大的变化,但是如果N接近无穷大的极限相对于N是恒定的,那么你仍然可以说它是O(1)。

答案 2 :(得分:19)

  

O(1)表示恒定时间和(通常)固定空间

只是澄清这些是两个单独的陈述。你可以在时间上有O(1),但在空间或其他任何地方可以有O(n)。

  

是否认识到即使O(1)也可能是不合需要的,即使接近常数?

O(1)可能是不切实际的巨大而且它仍然是O(1)。经常被忽略的是,如果你知道你将拥有一个非常小的数据集,那么常数比复杂性更重要,对于相当小的数据集,它是两者之间的平衡。如果数据集的常数和大小具有适当的比例,则O(n!)算法可以胜过O(1)。

O()表示法是对复杂性的衡量 - 不是算法将采用的时间,或者是给定算法对于给定目的“好”的纯度度量。

答案 3 :(得分:11)

我可以看到你在说什么,但我认为哈希表中的查找具有复杂度为O(1)的声明存在一些基本假设。

  • 合理设计散列函数以避免大量冲突。
  • 这组密钥几乎是随机分布的,或者至少没有故意设计使哈希函数表现不佳。

哈希表查找的最坏情况复杂度是O(n),但鉴于上述2个假设,这种情况极不可能。

答案 4 :(得分:8)

Hashtables是一种支持O(1)搜索和插入的数据结构。

哈希表通常具有键和值对,其中键用作函数的参数(hash function),它将确定值在其内部数据结构中的位置< / strong>,通常是数组。

由于插入和搜索仅取决于散列函数的结果,而不取决于散列表的大小和存储的元素数量,散列表具有O(1)插入和搜索。 < / p>

然而,有一个警告。也就是说,随着哈希表变得越来越满,将会有hash collisions,其中哈希函数将返回已被占用的数组的元素。这将需要 collision resolution 以便找到另一个空元素。

发生哈希冲突时,无法在O(1)时间内执行搜索或插入。但是,良好的冲突解决算法可以减少尝试查找另一个可设置空白点的次数,或者增加哈希表大小可以减少首先发生的冲突次数。

所以,理论上,只有一个由具有无限元素和完美哈希函数的数组支持的哈希表才能实现O(1)性能,因为这是唯一的方法避免哈希冲突,从而增加所需操作的数量。因此,对于任何有限大小的数组,由于散列冲突,它们将一次或小于O(1)。


我们来看一个例子。让我们使用哈希表来存储以下(key, value)对:

  • (Name, Bob)
  • (Occupation, Student)
  • (Location, Earth)

我们将使用包含100个元素的数组实现哈希表后端。

key将用于确定要存储(keyvalue)对的数组元素。为了确定元素,将使用hash_function

  • hash_function("Name")返回 18
  • hash_function("Occupation")返回 32
  • hash_function("Location")返回 74

从上面的结果中,我们将(key, value)对分配到数组的元素中。

array[18] = ("Name", "Bob")
array[32] = ("Occupation", "Student")
array[74] = ("Location", "Earth")

插入只需要使用哈希函数,并且不依赖于哈希表的大小及其元素,因此可以在O(1)时间内执行。

类似地,搜索元素使用散列函数。

如果我们要查找密钥"Name",我们将执行hash_function("Name")以找出数组中所需值所在的元素。

此外,搜索不依赖于散列表的大小和存储的元素数量,因此也是O(1)操作。

一切都很好。我们尝试添加("Pet", "Dog")的附加条目。但是,有一个问题,因为hash_function("Pet")返回 18 ,这与"Name"键的哈希值相同。

因此,我们需要解决此哈希冲突。让我们假设我们使用的哈希冲突解析函数发现新的空元素是 29

array[29] = ("Pet", "Dog")

由于此次插入中存在哈希冲突,因此我们的效果并不是O(1)。

当我们尝试搜索"Pet"密钥时,也会出现此问题,因为尝试通过执行"Pet"找到包含hash_function("Pet")密钥的元素,最初将始终返回18。

一旦我们查找元素18,我们就会找到键"Name"而不是"Pet"。当我们发现这种不一致时,我们需要解决冲突,以便检索包含实际"Pet"密钥的正确元素。重新发生哈希冲突是一种额外的操作,它使哈希表在O(1)时间内不执行。

答案 5 :(得分:4)

我无法与您见过的其他讨论对话,但至少有一个散列算法 保证为O(1)。

Cuckoo hashing维护一个不变量,以便哈希表中没有链接。插入是分摊O(1),检索总是O(1)。我从未见过它的实现,这是我在大学时新发现的东西。对于相对静态的数据集,它应该是一个非常好的O(1),因为它计算两个哈希函数,执行两次查找,并立即知道答案。

请注意,这是假设哈希计算也是O(1)。你可以争辩说,对于长度为K的字符串,任何散列都是最小的O(K)。实际上,你可以很容易地绑定K,比如K&lt; 1000.O(K)〜= O(1)对于K <1。 1000。

答案 6 :(得分:4)

关于你如何理解Big-Oh符号可能存在​​概念上的错误。这意味着,给定算法和输入数据集,当数据集的大小趋于无穷大时,算法运行时的上限取决于O函数的值。

当一个人说算法需要O(n)时间时,这意味着算法最坏情况的运行时间线性地取决于输入集的大小。

当算法花费O(1)时间时,它唯一意味着,给定计算函数f(n)的运行时间的函数T(f),存在一个自然正数k,使得T (f)&lt; k表示任何输入n。从本质上讲,它意味着算法运行时的上限不依赖于其大小,并且具有固定的有限限制。

现在,这并不意味着限制很小,只是它与输入集的大小无关。因此,如果我人为地为数据集的大小定义一个绑定k,那么它的复杂性将是O(k)== O(1)。

例如,在链表上搜索值的实例是O(n)操作。但如果我说一个列表最多有8个元素,则O(n)变为O(8)变为O(1)。

在这种情况下,我们使用trie数据结构作为字典(字符树,其中叶节点包含用作键的字符串的值),如果键是有界的,那么它的查找时间可以是考虑O(1)(如果我将一个字符字段定义为长度最多为k个字符,这在很多情况下都是合理的假设)。

对于哈希表,只要你假设哈希函数是好的(随机分布)并且足够稀疏以便最小化冲突,并且当数据结构足够密集时执行重新哈希,你确实可以认为它是一个哈希表O(1)访问时间结构。

总之,对于很多事情来说,O(1)时间可能会被高估。对于大型数据结构,足够的散列函数的复杂性可能不是微不足道的,并且存在足够的极端情况,其中冲突量使其表现得像O(n)数据结构,并且重新散列可能变得非常昂贵。在这种情况下,像AVL或B树这样的O(log(n))结构可能是一种更好的选择。

答案 7 :(得分:2)

HashTable查找相对于表中项目的数量是O(1),因为无论您添加到列表中的项目数量,散列单个项目的成本几乎相同,并创建hash会告诉你该项目的地址。


为了回答为什么这是相关的:OP询问为什么O(1)似乎被随意抛出,而在他的脑海里它显然不适用于许多情况。这个答案解释了在这种情况下O(1)时间确实是可能的。

答案 8 :(得分:2)

总的来说,我认为人们在不考虑正确性的情况下相对使用它们。例如,如果设计得很好并且你有一个很好的哈希,基于哈希的数据结构是O(1)(平均)查找。如果一切都哈希到一个桶,那么它是O(n)。一般来说,虽然一个人使用了一个好的算法并且密钥是合理分配的,所以在没有所有资格的情况下将它简单地称为O(1)是很方便的。与列表,树木等一样,我们考虑到某些实现,在讨论一般性时,在没有资格的情况下讨论它们会更方便。另一方面,如果我们讨论特定的实现,那么可能需要更加精确。

答案 9 :(得分:1)

哈希表实现实际上并非“完全”使用O(1),如果你测试一个,你会发现它们平均大约1.5次查找,以便在大型数据集中找到给定的密钥

(由于碰撞 DO 发生,碰撞时必须分配不同的位置)

另外,实际上,HashMaps由具有初始大小的数组支持,当它平均达到70%的饱满度时“增长”到双倍大小,这提供了相对良好的寻址空间。 70%的饱满度后,碰撞率增长得更快。

Big O理论指出,如果你有O(1)算法,甚至是O(2)算法,关键因素是输入集大小与插入/获取其中一个的步骤之间的关系程度。 O(2)仍然是恒定时间,所以我们只是将其近似为O(1),因为它或多或少是相同的。

实际上,只有一种方法可以使用O(1)获得“完美哈希表”,这需要:

  1. 全球完美哈希密钥生成器
  2. 无限制的寻址空间。
  3. 例外情况:如果您可以事先计算系统允许密钥的所有排列,并且目标后备存储地址空间被定义为可以容纳所有密钥的大小是允许的,那么你可以有一个完美的哈希,但它是一个“领域有限”的完美)

    考虑到固定的内存分配,至少有这个是不合理的,因为它会假设你有一些神奇的方法将无限量的数据打包到固定数量的空间而不会丢失数据,并且这在逻辑上是不可能的。

    所以回顾性地说,即使是相对朴素的哈希密钥生成器,在有限的内存中得到O(1.5)仍然是恒定的时间,我认为非常棒。

    后缀注释注意我在这里使用O(1.5)和O(2)。这些实际上并不存在于big-o中。这些仅仅是那些不了解大人物的人的理由。

    如果某事需要1.5步才能找到一个密钥,或者两步找到该密钥,或者一步找到该密钥,但步数从不超过2,是否需要1步或2是完全随机的,那么它仍然是O(1)的大O.这是因为无论如何您添加到数据集大小的许多项目,它仍然保持&lt; 2步骤。如果对于所有表&gt; 500个键需要两个步骤,然后你可以假设这两个步骤实际上是一步有2个部分,...仍然是O(1)。

    如果你不能做出这个假设,那么你根本就不是Big-O思考,因为那时你必须使用代表完成所有事情所需的有限计算步数的数字,而“一步”是没有意义的给你。只需了解Big-O与所涉及的执行周期数之间存在 NO 直接关联。

答案 10 :(得分:1)

O(1)确切地说,算法的时间复杂度受固定值的限制。这并不意味着它是恒定的,只是它无论输入值如何都是有界的。严格来说,许多据称O(1)时间算法实际上并不是O(1)而且速度太慢以至于它们对所有实际输入值都有限制。

答案 11 :(得分:1)

是的,垃圾收集确实会影响垃圾收集竞技场中运行的算法的渐近复杂性。这不是没有成本,但如果没有经验方法,很难分析,因为交互成本不是成分。

垃圾收集所花费的时间取决于所使用的算法。通常,现代垃圾收集器切换模式作为存储器填充以控制这些成本。例如,一种常见的方法是在内存压力较低时使用切尼式复制收集器,因为它支付的成本与实时设置的大小成比例以换取使用更多空间,并在内存压力时切换到标记和扫描收集器变得更大,因为即使它支付的成本与标记的实时设置和整个堆或死区的扫描成比例。当你添加卡片标记和其他优化等时,实际垃圾收集器的最坏情况成本实际上可能会更差,为某些使用模式选择额外的对数因子。

因此,如果你分配一个大的哈希表,即使你在其生命周期内使用O(1)搜索访问它,如果你在垃圾收集环境中这样做,垃圾收集器偶尔会遍历整个数组,因为它的大小为O(n),您将在收集期间定期支付该费用。

我们通常不接受算法的复杂性分析的原因是垃圾收集以非平凡的方式与您的算法交互。它的成本有多糟糕取决于你在同一过程中做了什么,所以分析不是成分。

此外,除了复制与紧凑与标记和扫描问题之外,实现细节还可以极大地影响由此产生的复杂性:

  1. 跟踪脏位等的增量垃圾收集器几乎可以使那些较大的重新遍历消失。
  2. 这取决于您的GC是否根据挂钟时间定期运行,或者与分配数量成比例运行。
  3. 标记和扫描样式算法是并发还是停止世界
  4. 是否将新鲜的分配标记为黑色,如果它们将它们留下白色,直到将它们放入黑色容器中。
  5. 您的语言是否允许修改指针可以让一些垃圾收集器一次性工作。
  6. 最后,在讨论算法时,我们正在讨论一个稻草人。渐近线永远不会完全包含环境的所有变量。您很少按照设计实现数据结构的每个细节。你在这里和那里借用一个特性,你删除一个哈希表,因为你需要快速无序密钥访问,你使用一个联合查找而不是不相交的集合与路径压缩和联合按级别来合并内存区域,因为你不能当你合并它们或你拥有什么时,我们可以支付与地区大小成比例的费用。这些结构被认为是原始的,渐近线在为“大型”结构规划整体性能特征时可以帮助您,但也知道常量也很重要。

    你可以用完美的O(1)渐近特征来实现那个哈希表,只是不要使用垃圾收集;将其从文件映射到内存并自行管理。你可能不会喜欢所涉及的常数。

答案 12 :(得分:0)

我认为,当许多人抛出“O(1)”这个词时,他们隐含地记住了一个“小”常数,无论“小”意味着什么。

你必须用上下文和常识来进行所有这些大O分析。它可能是一个非常有用的工具,或者它可能是荒谬的,这取决于你如何使用它。