直接回答:CPython 中 str 是不可变对象,s += piece 每次都要分配一块新内存并把旧内容整体拷贝过去,循环 n 次的总拷贝量是 O(n²);而 "".join(parts) 先遍历一遍算出总长度,一次分配、顺序填充,复杂度是 O(n),内存分配次数也从 n 次降到 1 次。数据量越大差距越明显,十万级片段时两者可差几个数量级。

parts = [f"item-{i}" for i in range(100_000)]

s = ""
for p in parts:        # O(n^2),每次都重新拷贝整个 s
    s += p

s = "".join(parts)     # O(n),一次分配

展开解析:CPython 对 s += x 有一个原地扩容优化——当 s 的引用计数为 1 且内存可扩展时,会尝试 realloc 避免拷贝,所以在 CPython 上小循环里 += 有时不算慢。但这个优化是实现细节:PyPy 等其他解释器没有;一旦 s 被其他变量引用(比如刚被打印过)就失效;而且每轮的临时对象仍要经历分配和释放。因此 PEP 8 和工程实践都推荐 join。另外 join 的参数是任意可迭代对象,传入生成器表达式可以省掉中间列表,但注意它内部会先转成序列以计算总长度。

实践要点:少量、可读性优先的拼接用 f-string;超大量或需要边生成边写出的场景用 io.StringIO,写满后一次性 getvalue();需要分隔符或拼接已有列表时 join 依然是首选。

追问方向:list 的 append 为什么是均摊 O(1)?str 不可变还带来了哪些好处(可作 dict key、驻留 interning、线程安全)?

(约 470 字)