在做 LeetCode 131. 分割回文串 的时候因为一个 Python 列表的拷贝操作导致险些超时。为此特地在此记录一下我所了解到的 Python 拷贝机制。

Python 拷贝类型

  • 直接赋值(=): 即对象的引用(可以理解为别名)。
  • 浅拷贝(copy): 拷贝指定对象,不会拷贝对象的内部的子对象。
  • 深拷贝(deepcopy): copy 模块的 deepcopy 方法,完全拷贝了目标对象及其子对象。

Python中的深拷贝与浅拷贝 这篇文章的图例很形象地描述了三种拷贝类型的差别

  1. 直接赋值

    直接赋值

  2. 浅拷贝

    浅拷贝

  3. 深拷贝

    深拷贝

直接赋值

Python 没有指针的概念,但 Python 的赋值操作就类似于新增一个指向原变量内存地址的指针。考虑操作b = a,这是一个赋值引用,对象 a 与 b 的变化完全同步。

拷贝

对于基本数据类型(如 int),Python 实际上采用的是引用计数的机制。无论是怎样进行赋值或拷贝,只要是相同的值,就会指向同一个内存地址,实际上这时就是增加了一个引用计数。即使是直接赋值,只要值相同,也是与先前相同的内存地址。一旦值不同,内存地址就不同。因此本文主要讨论的是较为复杂的复合对象的拷贝。

Python 的部分复杂内建类型具有.copy()方法,该方法能返回此对象的浅拷贝。更一般的方法是在copy包中使用copy()方法创建对象的浅拷贝。同样地,对象的深拷贝需要在copy包中使用deepcopy()方法创建。

Python 官方文档对两种拷贝机制的解释如下:

浅层与深层复制的区别仅与复合对象(即包含列表或类的实例等其他对象的对象)相关:

  • 浅层复制 构造一个新的复合对象,然后(在尽可能的范围内)将原始对象中找到的对象的 引用 插入其中。
  • 深层复制 构造一个新的复合对象,然后,递归地将在原始对象里找到的对象的 副本 插入其中。

然而,深拷贝相对来说会引发一些额外的问题(也是我在做题时遇到的):

深度复制操作通常存在两个问题, 而浅层复制操作并不存在这些问题:

  • 递归对象 (直接或间接包含对自身引用的复合对象) 可能会导致递归循环。
  • 由于深层复制会复制所有内容,因此可能会过多复制(例如本应该在副本之间共享的数据)。

因此在使用深拷贝时应当慎重,必要时能使用浅拷贝就使用浅拷贝

额外注意:列表的浅拷贝

前文提到,作为复杂数据结构对象,List是具有内建函数copy()的,但 Python 官方文档还提到了一种列表的浅拷贝方法:利用切片。如果需要深入了解 Python 切片的用法,可以看这里。官方文档原文如下:

制作列表的浅层复制可以通过赋值整个列表的切片完成,例如,copied_list = original_list[:]

这句话仔细看较难理解。注意到 Python 中的切片操作实际上返回的不是列表对象,而是一种特殊的切片对象slice,在 Python 内建函数文档中特别指出了这点:

class slice(stop)

class slice(start, stop[, step])

返回一个 slice 对象,代表由 range(start, stop, step) 指定索引集的切片。 其中参数 startstep 的默认值为 None。切片对象具有只读数据属性 startstopstep,只是返回对应的参数值(或默认值)。这几个属性没有其他明确的功能;不过 NumPy 和其他第三方扩展会用到。在使用扩展索引语法时,也会生成切片对象。例如: a[start:stop:step]a[start:stop, i]。 另一种方案是返回迭代器对象,可参阅 itertools.islice()

然而经常使用 Python 列表切片的人都知道,我们是几乎感知不到 slice 对象的存在的:

列表切片只会返回列表对象

这也就是说在列表对象进行切片操作时,在生成slice 对象后立刻就将其转为了一个新的List对象。然而这个对象仍然和原列表对象具有共享子类/共享部分资源的关系,否则无法解释其能够实现生成原列表对象浅拷贝的功能。

背后的原理我还不甚清楚,希望有相关知识的朋友能给出解答。