【问题标题】:Python list.clear() time and space complexity?Python list.clear() 时间和空间复杂度?
【发布时间】:2020-03-25 13:56:44
【问题描述】:

我正在写一篇关于 Python list.clear() 方法的博文,其中我还想提一下底层算法的时间和空间复杂度。我预计时间复杂度为 O(N),遍历元素并释放内存?但是,我发现了一个article,其中提到它实际上是一个 O(1) 操作。然后,我在 CPython implementation 中搜索了该方法的源代码,发现了一个我认为是 list.clear() 的实际内部实现的方法,但是我不确定它是否是。以下是该方法的源代码:

static int
_list_clear(PyListObject *a)
{
    Py_ssize_t i;
    PyObject **item = a->ob_item;
    if (item != NULL) {
        /* Because XDECREF can recursively invoke operations on
          this list, we make it empty first. */
        i = Py_SIZE(a);
        Py_SIZE(a) = 0;
        a->ob_item = NULL;
        a->allocated = 0;
        while (--i >= 0) {
           Py_XDECREF(item[i]);
        }
        PyMem_FREE(item);
    }
    /* Never fails; the return value can be ignored.
       Note that there is no guarantee that the list is actually empty
       at this point, because XDECREF may have populated it again! */
    return 0;
}

我可能是错的,但在我看来确实像 O(N)。另外,我发现了一个类似的问题here,但那里没有明确的答案。只是想确认list.clear() 的实际时间和空间复杂度,也许还有一点支持答案的解释。任何帮助表示赞赏。谢谢。

【问题讨论】:

  • 当您使用timeit 模块为一个包含一万个项目和一个包含一千万个项目的列表计时清除方法时发生了什么?
  • @wwii 使用timeit 对list.clear 计时是不可能的,因为timeit 运行代码的多次迭代,而list.clear 清除了列表,因此下一次迭代将在空的列表。如果你试图通过在每次迭代中创建一个新列表来解决这个问题,你就会混淆测量,因为创建列表肯定是 O(n)。
  • 直接观察list.clear复杂性的方法是测量list.clear的一次执行的时间(尽可能精确地使用时钟)在大小为一千个元素的列表上,然后是一万、十万、百万等。有了足够数量的元素,时间将变得可测量,并且线性显示。
  • @user4815162342 - 你只需要告诉 timeit 执行一个 loop。 - 它应该给出足够的结果。
  • @wwii 当然,这也可以,只是需要注意不要使用默认设置(在大多数情况下这是相当合理的)。

标签: python time-complexity cpython space-complexity


【解决方案1】:

如您所见,list.clear 的 CPython 实现是 O(n)。 code 迭代元素以减少每个元素的引用计数,但没有办法避免它。毫无疑问,这是一个 O(n) 操作,并且给定足够大的列表,您可以测量在 clear() 中花费的时间作为列表大小的函数:

import time

for size in 1_000_000, 10_000_000, 100_000_000, 1_000_000_000:
    l = [None] * size
    t0 = time.time()
    l.clear()
    t1 = time.time()
    print(size, t1 - t0)

输出显示线性复杂度;在我使用 Python 3.7 的系统上,它会打印以下内容:

1000000 0.0023756027221679688
10000000 0.02452826499938965
100000000 0.23625731468200684
1000000000 2.31496524810791

每个元素的时间当然很短,因为循环是用 C 编码的,每次迭代所做的工作很少。但是,正如上面的测量结果所示,即使是微乎其微的元素因素最终也会加起来。小的每个元素常数不是忽略操作成本的原因,或者同样适用于移动l.insert(0, ...) 中的列表元素的loop,这也是非常有效的 - 但很少有人会声称插入开始是 O(1)。 (而clear 可能会更多工作,因为 decref 将为引用计数实际上达到零的对象运行任意的析构函数链。)

在哲学层面上,有人可能会争辩说,在评估复杂性时应该忽略内存管理的成本,否则就不可能确定地分析任何事情,因为任何操作都可能触发 GC。这个论点是有道理的; GC 确实会偶尔出现且不可预测,其成本可以视为在所有分配中摊销。类似地,复杂性分析倾向于忽略malloc 的复杂性,因为它所依赖的参数(如内存碎片)通常与分配大小甚至与已分配块的数量没有直接关系。但是,在list.clear 的情况下,只有一个分配的块,不会触发 GC,代码仍在访问每个列表元素。即使假设 O(1) malloc 和摊销 O(1) GC,list.clear 仍然 花费的时间与列表中的元素数量成正比。

问题中链接的文章是关于 Python 语言的,没有提到特定的实现。不使用引用计数的 Python 实现,例如 Jython 或 PyPy,可能具有真正的 O(1) list.clear,并且对于他们来说,文章中的声明是完全正确的。因此,在概念层面上解释 Python 列表时,说清除列表是 O(1) 并没有错——毕竟,所有对象引用都在一个连续的数组中,并且你只释放它一次。这可能是您的博客文章应该提出的观点,这就是链接文章想要表达的意思。过早考虑引用计数的成本可能会使您的读者感到困惑,并使他们对 Python 的列表产生完全错误的想法(例如,他们可以想象它们是作为链表实现的)。

最后,在某些时候必须接受内存管理策略确实改变了一些操作的复杂性。例如,从调用者的角度来看,在 C++ 中销毁一个链表是 O(n);在 Java 或 Go 中丢弃它是 O(1)。而不是从垃圾收集语言的微不足道的意义上说,只是将相同的工作推迟到以后 - 移动收集器很可能只会遍历可到达的对象,并且确实永远不会访问被丢弃的链表的元素。引用计数使得丢弃大型容器在算法上类似于手动收集,而 GC 可以将其删除。虽然 CPython 的 list.clear 必须接触每个元素以避免内存泄漏,但 PyPy 的垃圾收集器从不 很可能需要做任何此类事情,因此具有真正的 O(1) @ 987654334@.

【讨论】:

  • 谢谢,答案写得很好。我并没有声称我 100% 理解它,但那是因为我的知识有限。非常感谢您的意见。
【解决方案2】:

正如其他答案所指出的,清除长度为 n 的列表需要 O(n) 时间。但我认为这里还有一点需要说明amortized 的复杂性。

如果你从一个空列表开始,并以任意顺序执行 N append 或 clear 操作,那么所有这些操作的总运行时间总是 O( N),给出 O(1) 的平均每个操作,无论列表在此过程中有多长,但其中许多操作是 clear。

与clear 一样,append 的最坏情况也是 O(n) 时间,其中 n 是列表的长度。这是因为当需要增加底层数组的容量时,我们必须分配一个新数组并复制所有内容。但是复制每个元素的成本可以“收取”到append 操作之一,该操作使列表达到需要调整数组大小的长度,这样 N @987654328从空列表开始的 @ 操作总是需要 O(N) 时间。

同样,在clear 方法中减少元素的引用计数的成本可以“收取”到首先插入该元素的append 操作,因为每个元素只能被清除一次。结论是,如果您在算法中使用列表作为内部数据结构,并且您的算法在循环内反复清除该列表,那么为了分析算法的时间复杂度,您应该将该列表中的 clear 计算为一个 O(1) 操作,就像您在相同情况下将 append 视为一个 O(1) 操作一样。

【讨论】:

  • 这个答案提出了一个很好且相关的观点,包括我自己在内的其他回应完全错过了这一点。虽然clear 确实是 O(n),但它所做的事情的性质保证它永远不会累积到 O(n^2),至少不能靠它自己。这是因为您无法清除同一个列表 n 次 - 除第一个之外的所有清除都将是 O(1),除非您在每个 clear 之前重新填充列表,但是重新填充本身就是 O(n ),所以 clear 没有引入 O(n^2) 复杂度。
【解决方案3】:

快速检查time 表明它是O(n)。

让我们执行以下操作并预先创建列表以避免开销:

import time
import random

list_1000000 = [random.randint(0,10) for i in range(1000000)]
list_10000000 = [random.randint(0,10) for i in range(10000000)]
list_100000000 = [random.randint(0,10) for i in range(100000000)]

现在检查清除这四个不同大小的列表所需的时间如下:

start = time.time()
list.clear(my_list)
end = time.time()
print(end - start))

结果:

list.clear(list_1000000) takes 0.015
list.clear(list_10000000) takes 0.074
list.clear(list_100000000) takes 0.64

需要更可靠的时间测量,因为这些数字在每次运行时都会发生偏差,但结果表明,随着输入大小的增长,执行时间几乎呈线性变化。因此,我们可以得出 O(n) 复杂度。

【讨论】:

  • @user4815162342 从 1 到 100 万个执行时间变化为 0 的列表是否清楚地表明了恒定​​的复杂性?我的意思是,如果不是恒定的,不应该至少移动一点吗?
  • @user4815162342 其实你是对的 - 我会编辑我的答案。
【解决方案4】:

忽略内存管理是 O(1)。说它是 O(N) 考虑内存管理是不完全正确的,因为考虑内存管理是很复杂的。

大多数时候,出于大多数目的,我们将内存管理的成本与触发它的操作的成本分开处理。否则,您可能做的几乎所有事情都会变成 O(谁知道),因为几乎任何操作都可能触发垃圾收集传递或昂贵的析构函数或其他东西。哎呀,即使在像 C 这样带有“手动”内存管理的语言中,也不能保证任何特定的 malloc 或 free 调用都会很快。

有一个论点是引用计数操作应该被区别对待。毕竟,list.clear 显式执行了等于列表长度的Py_XDECREF 操作,即使没有对象因此被释放或最终确定,引用计数本身也必然会花费与列表长度成正比的时间。

如果您计算Py_XDECREF 操作list.clear 显式执行,但忽略可能由引用计数操作触发的任何析构函数或其他代码,并且您假设PyMem_FREE 是常数时间,那么list.clear 是O( N),其中 N 是列表的原始长度。如果你不考虑所有内存管理开销,包括显式的Py_XDECREF 操作,list.clear 是 O(1)。如果算上所有内存管理成本,那么list.clear 的运行时间不能被列表长度的任何函数逼近。

【讨论】:

    猜你喜欢
    • 2012-08-14
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-09-12
    • 2016-02-13
    • 1970-01-01
    • 2014-04-29
    • 1970-01-01
    相关资源
    最近更新 更多