为什么天真的字符串连接在一定长度之上变成二次方?

时间:2017-06-11 18:53:33

标签: python cpython python-internals

通过重复的字符串连接来构建字符串是一种反模式,但我仍然很好奇为什么它的性能在字符串长度超过大约10 **后从线性切换到二次方**:

# this will take time linear in n with the optimization
# and quadratic time without the optimization
import time
start = time.perf_counter()
s = ''
for i in range(n):
    s += 'a'
total_time = time.perf_counter() - start
time_per_iteration = total_time / n

例如,在我的机器上(Windows 10,python 3.6.1):

  • 对于10 ** 4 < n < 10 ** 6time_per_iteration几乎完全恒定在170±10μs
  • 对于10 ** 6 < ntime_per_iteration几乎完全线性,在n == 10 ** 7达到520μs。

time_per_iteration中的线性增长相当于total_time中的二次增长。

线性复杂性来自最近的CPython版本(2.4+)中的optimization reuse the original storage,如果没有对原始对象的引用。但我预计线性性能会无限期地持续下去,而不是在某个时刻切换到二次方。

我的问题是基于this comment。出于某些奇怪的原因运行

python -m timeit -s"s=''" "for i in range(10**7):s+='a'"

需要非常长的时间(比二次方长得多),所以我从未得到timeit的实际时序结果。相反,我使用了一个简单的循环来获得性能数字。

更新

我的问题也可能标题为“如果没有过度分配,列表式append如何才能O(1)表现?”。通过观察小型字符串上的常量time_per_iteration,我假设字符串优化必须过度分配。但realloc(意外地对我而言)在扩展小内存块时非常成功地避免了内存复制。

3 个答案:

答案 0 :(得分:14)

最后,平台C分配器(如malloc())是内存的最终来源。当CPython尝试重新分配字符串空间以扩展其大小时,系统C realloc()确实会确定发生的细节。如果字符串开头是“短”的,那么系统分配器找到与其相邻的未使用内存的可能性是不错的,因此扩展大小只是C库的分配器更新某些指针的问题。但是在重复了这么多次之后(再次依赖于平台C分配器的细节), 将耗尽空间。此时,realloc()将需要将整个字符串复制到一个全新的更大的可用内存块。这是二次时间行为的来源。

请注意,例如,增长Python列表面临着相同的权衡。但是,列表是设计的要增长,因此CPython故意要求比当时实际需要更多的内存。随着列表的增长,这种分配的数量会增加,足以使realloc()需要复制整个列表的情况很少 - 到目前为止。但字符串优化不会过度分配,因此realloc()需要更频繁地复制的情况。

答案 1 :(得分:4)

{{1}}

当通过附加增长连续数组数据结构(如上所示)时,如果在重新分配数组时保留的额外空间与数组的当前大小成比例,则可以实现线性性能。显然,对于大字符串这种策略没有遵循,很可能是为了不浪费太多内存。而是在每次重新分配期间保留固定数量的额外空间,导致二次时间复杂度。为了理解后一种情况下二次性能的来源,假设根本没有进行过度分配(这是该策略的边界情况)。然后在每次迭代时,必须执行重新分配(需要线性时间),并且完整运行时是二次的。

答案 2 :(得分:3)

TL; DR:仅仅因为字符串连接在某些情况下被优化并不意味着它必然是O(1),它并不总是O(n)。什么决定了性能最终是你的系统,它可能是聪明的(小心!)。列出&#34; garantuee&#34;分摊O(1)追加操作仍然更快,更好地避免了重新分配。

这是一个极其复杂的问题,因为很难定量测量&#34;。如果您阅读公告:

  

s = s + "abc"s += "abc"形式的语句中的字符串连接现在可以在某些情况下更有效地执行。

如果你仔细看看它,那么你会注意到它提到了某些情况&#34;。棘手的是找出这些特定的情况。一个是非常明显的:

  • 如果其他内容包含对原始字符串的引用。

否则更改s无法安全。

但另一个条件是:

  • 如果系统可以在O(1)中重新分配 - 这意味着无需将字符串的内容复制到新位置。

那是不是很棘手。因为系统负责进行重新分配。你无法在python中控制任何东西。但是你的系统很聪明。这意味着在许多情况下,您实际上可以进行重新分配而无需复制内容。 You might want to take a look at @TimPeters answer, that explains some of it in more details

我将从实验主义者的角度来解决这个问题。

通过检查ID更改的频率(因为CPython中的id函数返回内存地址),您可以轻松检查实际需要复制的重新分配数量:

changes = []
s = ''
changes.append((0, id(s)))
for i in range(10000):
    s += 'a'
    if id(s) != changes[-1][1]:
        changes.append((len(s), id(s)))

print(len(changes))

每次运行(或几乎每次运行)都会给出不同的数字。它在我的计算机上大约500左右。即使对于range(10000000),我的计算机上也只有5000。

但是如果你认为他们真的很擅长&#34;避免&#34;复制你错了。如果您将其与list需要的调整项数量进行比较(list有意过度分配,以便append摊销O(1)):

import sys

changes = []
s = []
changes.append((0, sys.getsizeof(s)))
for i in range(10000000):
    s.append(1)
    if sys.getsizeof(s) != changes[-1][1]:
        changes.append((len(s), sys.getsizeof(s)))

len(changes)

只需要105次重新分配(总是)。

我提到realloc可能很聪明,我故意保留&#34;尺寸&#34;当reallocs发生在列表中时。许多C分配器试图避免内存碎片,至少在我的计算机上,分配器根据当前大小做了不同的事情:

# changes is the one from the 10 million character run

%matplotlib notebook   # requires IPython!

import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure(1)
ax = plt.subplot(111)

#ax.plot(sizes, num_changes, label='str')
ax.scatter(np.arange(len(changes)-1), 
           np.diff([i[0] for i in changes]),   # plotting the difference!
           s=5, c='red',
           label='measured')
ax.plot(np.arange(len(changes)-1), 
        [8]*(len(changes)-1),
        ls='dashed', c='black',
        label='8 bytes')
ax.plot(np.arange(len(changes)-1), 
        [4096]*(len(changes)-1),
        ls='dotted', c='black',
        label='4096 bytes')
ax.set_xscale('log')
ax.set_yscale('log')
ax.set_xlabel('x-th copy')
ax.set_ylabel('characters added before a copy is needed')
ax.legend()
plt.tight_layout()

enter image description here

请注意,x轴代表完成的副本数量&#34;不是字符串的大小!

这个图对我来说实际上非常有趣,因为它显示了清晰的模式:对于小数组(最多465个元素),步骤是不变的。它需要为每添加8个元素重新分配。然后,它需要为每个添加的角色实际分配一个新数组,然后在大约940时,所有投注都将关闭,直到(大致)一百万个元素。然后它似乎以4096字节的块分配。

我的猜测是C分配器对不同大小的对象使用不同的分配方案。小对象以8个字节的块分配,然后对于大于小但仍然很小的阵列,它停止分配,然后对于中等大小的阵列,它可能将它们定位在它们可能适合的位置#34;。然后对于巨大的(比较说)数组,它以4096字节的块分配。

我猜8字节和4096字节不是随机的。 8字节是int64(或float64又名double)的大小,我在64位计算机上使用python编译为64位。 4096是我电脑的页面大小。我假设有很多&#34;对象&#34;需要具有这些大小,因此编译器使用这些大小是有意义的,因为它可以避免内存碎片。

您可能知道但只是为了确保:对于O(1)(摊销)附加行为,分配必须取决于大小。如果分配是恒定的,那么它将是O(n**2)(分配越大,常数因子越小,但它仍然是二次的)。

因此,在我的计算机上,运行时行为总是O(n**2),除了长度(大约)1 000到1 000 000之外 - 它似乎确实未定义。在我的测试运行中,它能够添加许多(十万)元素而无需复制,因此它可能看起来像O(1)&#34;什么时候定的。

请注意,这只是我的系统。它可能在另一台计算机上甚至在我的计算机上使用另一台编译器时看起不要太认真对待这些。我自己提供了编写代码的代码,因此您可以自己分析系统。

如果你过度分配字符串,你还会问(在评论中)是否存在缺点。这很简单:字符串是不可变的。因此任何过度分配的字节都会浪费资源。只有少数几种情况确实会增长,这些都被认为是实施细节。开发人员可能不会丢弃空间以使实施细节表现更好,some python developers also think that adding this optimization was a bad idea