Python 为什么原始字符串连接在一定长度以上会变成二次型?
通过重复串接构建字符串是一种反模式,但我仍然很好奇,为什么在字符串长度超过大约10**6后,它的性能会从线性变为二次: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
# 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
时间几乎是完全线性的,在
时达到520µs李>n==10**7
时间的线性增长
相当于总时间的二次增长
线性复杂性源于最近的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试图重新分配字符串空间以扩展其大小时,实际上是系统Crealloc()
决定了发生的细节。如果字符串开始时是“短”的,那么系统分配器很有可能会找到与其相邻的未使用内存,因此扩展大小只是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()