Python 为什么原始字符串连接在一定长度以上会变成二次型?

Python 为什么原始字符串连接在一定长度以上会变成二次型?,python,cpython,python-internals,Python,Cpython,Python Internals,通过重复串接构建字符串是一种反模式,但我仍然很好奇,为什么在字符串长度超过大约10**6后,它的性能会从线性变为二次: # 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_tim

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

# 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
    ,每次迭代的
    时间几乎完全恒定在170±10µs
  • 对于
    10**6
    ,每次迭代的
    时间几乎是完全线性的,在
    n==10**7
    时达到520µs
每次迭代的
时间的线性增长
相当于
总时间的二次增长

线性复杂性源于最近的CPython版本(2.4+)中的,如果没有引用,将保留原始对象。但我预计线性性能将无限期地持续下去,而不是在某个时刻切换到二次型

我的问题是根据你提出的。因为一些奇怪的原因

python -m timeit -s"s=''" "for i in range(10**7):s+='a'"
花费了难以置信的长时间(比二次型长得多),所以我从未从
timeit
得到实际的计时结果。因此,我使用了如上所述的简单循环来获得性能数字

更新:

我的问题也可以被称为“像
append
这样的列表如何在没有过度分配的情况下具有
O(1)
性能?”。通过观察小尺寸字符串上的常量
每次迭代的时间
,我假设字符串优化必须过度分配。但是,
realloc
在扩展小内存块时非常成功地避免了内存复制(这对我来说是出乎意料的)

[XXXXXXXXXXXXXXXXXX............]
 \________________/\__________/
     used space      reserved
                      space

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

最后,平台C分配器(如
malloc()
)是最终的内存源。当CPython试图重新分配字符串空间以扩展其大小时,实际上是系统C
realloc()
决定了发生的细节。如果字符串开始时是“短”的,那么系统分配器很有可能会找到与其相邻的未使用内存,因此扩展大小只是C库分配器更新某些指针的问题。但重复此操作若干次后(再次取决于平台C分配器的详细信息),它将耗尽空间。此时,
realloc()
需要将整个字符串复制到一个全新的更大的可用内存块中。这就是二次时间行为的来源


例如,请注意,增加Python列表面临相同的权衡。然而,列表是为了增长而设计的,因此CPython故意要求比当时实际需要的内存更多的内存。这种过度分配的数量随着列表的增长而增加,这足以使
realloc()
到目前为止很少需要复制整个列表。但是字符串优化并没有过度分配,使得
realloc()
需要更频繁地复制的情况发生;DR:仅仅因为字符串串联在某些情况下是优化的,并不意味着它一定是
O(1)
,它只是不总是
O(n)
。决定性能的是你的系统,它可能是智能的(小心!)。列出“garantuee”摊销
O(1)
append操作在避免再分配方面仍然更快更好


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

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

如果你仔细看一下,你会注意到它提到了“某些情况”。棘手的事情是找出这些特定的环境是什么。一个是显而易见的:

  • 如果其他内容包含对原始字符串的引用
否则,更改
s
是不安全的

但另一个条件是:

  • 如果系统可以在
    O(1)
    中进行重新分配-这意味着无需将字符串内容复制到新位置
那是很难的。因为系统负责重新分配。这在python中是无法控制的。然而,您的系统是智能的。这意味着在许多情况下,您实际上可以进行重新分配,而无需复制内容

我将从实验主义者的角度来处理这个问题

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

这会在每次运行(或几乎每次运行)时给出不同的编号。在我的电脑上大约有500个。即使是
范围(10000000)
在我的电脑上也只有5000

但如果你认为这真的很擅长“避免”拷贝,那你就错了。如果将其与
列表
需求的调整大小数量进行比较(
列表
s过度分配,因此
追加
摊销
O(1)
):

这只需要105次重新分配(始终)


我提到过
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))
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)
# 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()