繁体   English   中英

Python list.clear() 时间和空间复杂度?

[英]Python list.clear() time and space complexity?

我正在写一篇关于 Python list.clear()方法的博客文章,其中我还想提到底层算法的时间和空间复杂性。 我预计时间复杂度为O(N) ,迭代元素并释放内存? 但是,我发现一篇文章提到它实际上是一个O(1)操作。 然后,我在 CPython 实现中搜索了该方法的源代码,发现了一个我认为是list.clear()的实际内部实现的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) 另外,我在这里发现了一个类似的问题,但那里没有明确的答案。 只想确认list.clear()的实际时间和空间复杂度,也许还有一些支持答案的解释。 任何帮助表示赞赏。 谢谢。

正如您所注意到的, list.clearCPython实现是 O(n)。 代码迭代元素以减少每个元素的引用计数,但没有办法避免它。 毫无疑问,这是一个 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, ...)的列表元素的循环,这也非常有效 - 但很少有人会声称在开始时插入是 O(1)。 (并且clear可能会做更多的工作,因为 deref 将为引用计数实际上为零的对象运行任意的析构函数链。)

在哲学层面上,人们可能会争辩说,在评估复杂性时应该忽略内存管理的成本,否则就不可能确定地分析任何事情,因为任何操作都可能触发 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) list.clear

这是 O(1) 忽略内存管理。 说它是 O(N) 内存管理是不太正确的,因为内存管理是复杂的。

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

有一种观点认为,应区别对待引用计数操作。 毕竟, list.clear显式执行与列表长度相等的许多Py_XDECREF操作,即使结果没有对象被释放或最终确定,引用计数本身也必然需要与列表长度成正比的时间。

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

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

如果您从一个空列表开始,并以任何顺序执行N 个appendclear操作,那么所有这些操作的总运行时间始终为 O( N ),每个操作的平均时间为 O(1),无论多长时间list 进入过程中,无论这些操作中有多少是clear

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

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

快速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) 复杂度的结论。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM