【问题标题】:How does Python remove elements from a list so quickly?Python 如何如此快速地从列表中删除元素?
【发布时间】:2015-11-29 04:56:36
【问题描述】:

我现在正在学习 Python,并试图了解容器在实践中的工作原理。 有一个问题我无法解释。 假设,我创建了一个非常大的列表:

>>> l = [i for i in range(100000000)] # ~3 sec

创建它需要大约 3 秒(我使用升序而不是相同的值来避免可能的优化)

我们可以看到here,删除运营成本O(n)。但是当我从列表中间删除一个元素时,它会立即返回(与任何其他简单命令一样快,如元素访问)

>>> del l[50000000] # instantly (< 0.1 sec)

在此之后,我可以在删除后不到 3 秒内访问元素 l[25000000]l[75000000],并且它也会立即执行(因此,我无法通过延迟或后台删除来解释这一点)。

有人可以解释一下,它是如何在内部完成的吗?该列表实际上是作为某种树实现的吗?这听起来很奇怪,而且它违反了constant time element access 的要求。

这是一种常见的优化,如 C++ 中的返回值优化,还是很少见的,仅特定于我的平台/版本?

我使用 Linux 和 Python 3.4.1(Python 2.7.9 显示相同的结果)。

【问题讨论】:

  • 1) 这不是“时间复杂性”的意思。 2) 展示你要进行基准测试的完整代码 3) 展示你是如何做到的。
  • “列表实际上是作为某种树实现的吗?” - 不,也没有进行一些 “背景移除” . Python是开源的,可以看实现:hg.python.org/cpython/file/tip/Objects/listobject.c
  • 我使用的是 python 解释器,而不是单独的程序。我已经提供了我输入的所有代码。在时间复杂度下,我指的是操作的渐近复杂度,而不是执行所需的时间。
  • @Vasily 是的,完全。您正在“试图了解容器的工作原理”,这是一种方法。另请注意,您需要在您的 cmets 中包含 @username 以告知其他用户您正在处理他们。
  • listobject.c 源代码很容易阅读,前提是您对 C 语法有一定了解。

标签: python optimization time-complexity python-internals


【解决方案1】:

我决定把我的一组 cmets 变成一个正确的答案。

首先,让我们澄清一下你这样做时发生了什么:

>>> l = [i for i in range(100000000)]

这里发生了三件事:

  1. 100000000 int 正在创建对象。在 CPython 中创建对象需要分配内存并将内容放入该内存中,这需要时间。
  2. 您正在运行一个循环。这会极大地影响性能:[i for i in range(...)]list(range(...)) 慢得多。
  3. 正在动态创建大型列表。

阅读您的问题,您似乎只考虑最后一点,而忽略了其他点。因此,您的时间安排不准确:创建一个大列表不需要 3 秒,它只需要这 3 秒的一小部分。

这个分数有多大是一个有趣的问题,仅使用 Python 代码很难回答,但我们仍然可以尝试。具体来说,我会尝试使用以下语句:

>>> [None] * 100000000

这里CPython不必创建大量对象(只有None),不必运行循环,可以为列表分配一次内存(因为它预先知道大小)。

时间不言自明:

$ python3 -m timeit "list(range(100000000))"
10 loops, best of 3: 2.26 sec per loop
$ python3 -m timeit "[None] * 100000000"
10 loops, best of 3: 375 msec per loop

现在,回到你的问题:删除项目怎么样?

$ python3 -m timeit --setup "l = [None] * 100000000" "del l[0]"
10 loops, best of 3: 89 msec per loop
$ python3 -m timeit --setup "l = [None] * 100000000" "del l[100000000 // 4]"
10 loops, best of 3: 66.5 msec per loop
$ python3 -m timeit --setup "l = [None] * 100000000" "del l[100000000 // 2]"
10 loops, best of 3: 45.3 msec per loop

这些数字告诉我们一些重要的事情。请注意,2 × 45.3 ≈ 89。还有 66.5 × 4 / 3 ≈ 89。

这些数字准确地说明了线性复杂度的含义。如果一个函数具有时间复杂度kn(即O(n)),这意味着如果我们将输入加倍,我们将加倍时间;如果我们将输入大小增加 4/3,则时间增加 4/3。

这就是这里发生的事情。在 CPython 中,我们的 100000000 项列表是一个包含指向 Python 对象的指针的连续内存区域:

l = |ptr0|ptr1|ptr2|...|ptr99999999|

当我们运行del l[0] 时,我们正在从右向左移动ptr1,覆盖ptr0。其他元素也一样:

l = |ptr0|ptr1|ptr2|...|ptr99999999|
     ^^^^
         ` item to delete

l = |ptr1|ptr2|...|ptr99999999|

因此,当我们运行del l[0] 时,我们必须将 99999998 指针向左移动。这与del l[100000000 // 2] 不同,后者只需要移动一半的指针(前半部分的指针不需要移动)。 “移动一半的指针”等于“执行一半的操作”,大致意思是“运行一半的时间”(这并不总是正确的,但时间表明在这种情况下这是正确的)。

【讨论】:

  • 因此,如果对象分配比分配引用慢 6 倍,并且引用的分配和初始化(使用 Nones)比移动它们慢 8 倍。嗯,我想我可以继续下去。虽然这对我来说似乎是一个很大的开销。
  • 除此之外,阅读代码,正如@jonrsharpe 建议的那样,列表是由bit by bit 创建的,而deletion 主要使用memmove
【解决方案2】:

我不确定您为什么认为删除单个元素需要 3 秒。

您的初始时间是 100000000 次单独的追加操作。每一个都需要几分之一秒;您的删除操作需要类似的时间。

无论如何,正如 Bartosz 所指出的,O(n) 复杂度并不意味着所有操作都需要相同的时间长度,而是意味着时间长度与列表的长度成正比。

【讨论】:

  • 值得注意的是,底层对象将在上升过程中调整大小,但不会在下降过程中(直到达到半满),这会增加更多开销。
  • 那么,您的回答是“不存在复杂性问题,只是复杂性相同,操作时间不同”?是的,我同意,我的初始时间是 10m*time(append)。但是当我从数组中间删除元素时 - 我必须移动 5m 元素,否则我失去了在恒定时间访问元素的可能性。所以,时间应该是 5m*time(assign)。这两个操作都是缓存友好的。我无法想象为什么用计数器值分配元素的时间应该是用邻居值分配元素的 50 倍。
  • @Vasily 但这还不是全部。删除只是在一块连续的内存中移动现有的引用,而列表的创建还涉及创建列表要引用的对象并调整列表的大小以适应它们。
  • @jonrsharpe 是的,这是一个有趣的想法。我认为创建引用对象所需的时间可以忽略不计。但现在我尝试先用零初始化整个列表,然后用 1 分配它们。它要快得多(大约 0.3 秒)。虽然,仍然明显比删除慢。看起来答案可能比我预期的要复杂。不仅仅是棘手的删除逻辑,还有一些 Python 内存管理。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2015-03-28
  • 2021-09-16
  • 2018-07-14
  • 2010-10-13
  • 2021-03-10
  • 2019-11-09
相关资源
最近更新 更多