目录
[2. 切片的实现细节:slice 对象与 getitem](#2. 切片的实现细节:slice 对象与 getitem)
[3. 步长为负的彻底理解](#3. 步长为负的彻底理解)
[4. 切片赋值的高级技巧](#4. 切片赋值的高级技巧)
[5. 切片作为视图:memoryview 与 array 模块](#5. 切片作为视图:memoryview 与 array 模块)
[1. 数据被意外修改 (数据泄漏)](#1. 数据被意外修改 (数据泄漏))
[2. 可能阻止内存释放 (内存泄露)](#2. 可能阻止内存释放 (内存泄露))
[核心区别:视图 vs 副本 (View vs Copy)](#核心区别:视图 vs 副本 (View vs Copy))
[2.追加 ------ 动态扩容的艺术](#2.追加 —— 动态扩容的艺术)
[1.append 的过度扩容(overallocation)机制](#1.append 的过度扩容(overallocation)机制)
[2. extend 与 += 的细微区别](#2. extend 与 += 的细微区别)
[3. 预先分配容量以减少扩容](#3. 预先分配容量以减少扩容)
[4. append 与列表推导式的取舍](#4. append 与列表推导式的取舍)
[3. 插入 ------ 成本高昂的灵活](#3. 插入 —— 成本高昂的灵活)
[1. insert 的时间复杂度与内存移动](#1. insert 的时间复杂度与内存移动)
[2. 批量插入:切片赋值 vs 多次 insert](#2. 批量插入:切片赋值 vs 多次 insert)
[3. 在头部插入的替代方案:collections.deque](#3. 在头部插入的替代方案:collections.deque)
[4. 插入的实用模式:维持有序列表](#4. 插入的实用模式:维持有序列表)
[1. 简单基准对比](#1. 简单基准对比)
[2. 实战建议汇总](#2. 实战建议汇总)
[1. 对同一个列表的多个切片同时赋值](#1. 对同一个列表的多个切片同时赋值)
[2. 空切片与边界情况](#2. 空切片与边界情况)
[3. list 与 array 的切片复制效率](#3. list 与 array 的切片复制效率)
在入门阶段,我们已经学会了创建列表、按索引访问、简单的增删改查。但要想真正写出高效、优雅的代码,必须深入理解切片 、追加 和插入的底层机制、性能特征以及进阶用法。本文假设你已经熟悉列表的基本操作,我们将聚焦于那些容易被忽视的细节、高级技巧和实用模式。
1.切片---不只是一段子列表
1.切片的内存模型:浅拷贝与共享
很多人知道 lst[start:stop] 返回一个新列表,但新列表中的元素与原列表的关系是什么呢?
-
不可变对象 (int, str, tuple):新列表中的元素是原列表中元素值的副本(实际上是小整数的驻留或字符串的引用,但因为不可变,效果上等价于新值)。
-
可变对象 (list, dict, set 等):新列表中的元素是原列表中对象引用的副本,因此通过新列表修改内部可变对象会影响到原列表。
original = [[1, 2], [3, 4]]
sliced = original[:] # 浅拷贝
sliced[0][0] = 99
print(original) # [[99, 2], [3, 4]] ------ 原列表也被修改
这就是浅拷贝。要完全独立,需要深拷贝:copy.deepcopy(original)。
2. 切片的实现细节:slice 对象与 __getitem__
当你写 lst[1:5:2] 时,Python 会创建一个 slice(1, 5, 2) 对象,然后调用列表的 __getitem__ 方法。这意味着你可以显式创建 slice 对象,使代码更灵活:
lst = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90] # 定义一个列表
indices = slice(1, 5, 2)
print(lst[indices]) # 等价于 lst[1:5:2]
3. 步长为负的彻底理解
负步长意味着从右向左提取,此时开始索引必须大于结束索引 (否则结果为空)。理解取出的顺序:起始位置包括 start,然后每次加上步长(负数),直到越过 stop(注意 stop 仍然是不包含的)。
nums = [0, 1, 2, 3, 4, 5]
print(nums[4:1:-1]) # 从索引4开始,步长-1,到索引2为止(不包含索引1)-> [4,3,2]
一个常见用途是反转:nums[::-1] 从开头到末尾,步长-1,结果整个反转。
4. 切片赋值的高级技巧
我们提到过 lst[start:stop] = iterable,但还有一些微妙之处:
-
右侧可以是任何可迭代对象,不仅限于列表。
-
如果
step不为 1(即扩展切片),则右侧的元素个数必须严格等于切片长度,否则抛出ValueError。nums = [0, 1, 2, 3, 4, 5]
步长为2,切片长度为3(索引 0,2,4)
nums[::2] = [10, 20, 30] # 成功
nums[::2] = [10, 20] # ValueError: attempt to assign sequence of size 2 to extended slice of size 3
利用这一特性,可以高效地替换偶数位置的元素。
5. 切片作为视图:memoryview 与 array 模块
切片操作(如 lst[1:5:2])确实会创建一个新的列表 (对于列表而言),其中包含原列表部分元素的浅拷贝。这种复制行为会消耗额外的内存和时间,对于大列表来说可能代价较大。
为什么切片会创建副本?
Python 的设计选择是:列表切片返回新列表,以保证不可变性(让原列表不受影响)和简化程序逻辑。这是一种安全且直观的做法,但牺牲了性能。
性能影响
-
时间复杂度:O(k),k 为切片长度(元素个数)。
-
空间复杂度:新列表占用额外的内存。
例如,对一个包含 100 万个元素的列表取前 50 万个,就会复制 50 万个引用,耗时几十毫秒到几百毫秒,内存翻倍。
如何避免创建副本?
如果你只需要读取切片中的元素而不修改它们,可以使用以下方法避免复制:
| 方法 | 说明 |
|---|---|
| 使用索引访问 | 例如用 for i in range(start, end, step): 逐个访问。 |
使用 itertools.islice |
返回一个迭代器,不创建新列表。例如 import itertools; s = itertools.islice(lst, 1, 5, 2); for val in s: pass |
使用 array 或 numpy |
对于数值数组,numpy 的切片返回视图(不复制数据)。 |
| 只取单元素或少数元素 | 直接用索引,无需切片。 |
对于不可变序列(如字符串、元组)呢?
-
字符串切片也会创建新字符串(因为字符串是不可变的,但 CPython 有时会优化,但通常视为 O(k))。
-
元组切片也会创建新元组。
对于大数组,切片复制代价高。如果操作的是同类型数值,可以使用 array 模块或 numpy 的视图(view)概念。Python 内置的 memoryview 也可以对字节数据进行切片而不复制。
import array
a = array.array('i', range(1000000))
# a[0:1000] 会复制1000个整数,消耗内存
# memoryview 可以避免复制,但要求对象支持缓冲区协议
在普通列表上无法实现真正的"视图",但可以通过 itertools.islice 获得迭代器,避免内存复制。
NumPy
NumPy 不是 Python 的标准库,需要单独安装。
视图
理解"视图":数据共享,而非复制
一个"视图"数组和它原始的"基础"数组,在底层共用同一块数据内存。你可以通过一个数组的 base 属性来快速判断它是不是视图。
-
如果
arr.base是None:说明这个数组自己拥有数据,是原始数组。 -
如果
arr.base是其他数组对象:说明这个数组只是一个视图,数据来自那个被引用的原始数组。import numpy as np
创建一个基础数据
arr = np.array([1, 2, 3, 4, 5])
视图:通过切片创建视图
view = arr[1:4]
副本:显式创建副本
copy = arr.copy()
检查 .base 属性
print(view.base is arr) # True,验证了它是视图
print(copy.base is None) # True,验证了它是数据拥有者
"视图"的核心优势与应用场景
这种设计的主要优势在于:
-
性能:创建视图不需要复制数据,因此几乎是即时完成的,这对于处理大型多维数组非常关键。
-
内存效率 :视图与原始数据共享内存,不额外占用空间。这意味着即使创建百万数据量的切片,内存开销也几乎为 0。
-
原地修改:通过视图修改数据,会直接反映到原始数组中。
-
连续内存:NumPy 数组存储在连续内存中,使得切片操作非常高效。
正是为了满足科学计算中对性能 和内存效率的极致追求,NumPy才选择了与Python列表完全不同的"视图机制"。
视图的风险:意外修改与内存泄漏
使用"视图"机制有两个主要的潜在风险需要留意。
1. 数据被意外修改 (数据泄漏)
因为视图与原始数组共享数据,所以你可能在无意间修改了原始数据。
import numpy as np
a2 = np.array([[12, 5, 2, 4], [7, 6, 8, 8], [1, 6, 7, 7]])
# 通过切片创建子数组视图
a2_sub = a2[:2, :2]
a2_sub[0, 0] = 99 # 看似只是修改子数组
# 结果:原始数组也被改变了!
print(a2)
# 输出:
# [[99 5 2 4]
# [ 7 6 8 8]
# [ 1 6 7 7]]
这个例子清楚地展示了通过视图修改数据如何直接影响了原始数组。
2. 可能阻止内存释放 (内存泄露)
视图会"引用"原始数据。如果原始数组很大,而你创建了它的一个小切片视图后,又删除了原始数组引用,只要这个视图还存在 ,它所引用的整个原始数组内存都无法被释放。这可能导致程序意外地占用大量内存。
核心区别:视图 vs 副本 (View vs Copy)
为了帮助你更清晰地对比,我制作了下面的表格:
| 特性 | 视图 (View) | 副本 (Copy) |
|---|---|---|
| 内存 | 共享原始数组内存,不额外占用 | 分配独立内存,占用空间 |
| 性能 | 创建速度极快(O(1)时间) | 创建速度慢(O(n)时间,n为数据量) |
| 修改影响 | 修改视图会改变原始数组 | 修改副本不影响原始数组 |
| 检查 | arr.base 指向原始数组 |
arr.base 为 None |
创建视图的注意事项
-
当切片是不连续时 :使用"高级索引"(如整数数组索引)时,NumPy很可能会生成一个副本,而不是视图。
-
当切片导致形状改变时:某些形状的改变也可能导致生成副本。
视图的实现原理
NumPy实现视图的核心机制包括 data、shape 和 strides。
首先看一下原始数组的信息:
python
import numpy as np
a = np.arange(1, 7).reshape(2, 3)
print(a.shape) # (2, 3)
print(a.strides) # (24, 8) # 从一行开头到下一行开头需跳转24字节,列方向相邻元素间隔8字节
现在,创建视图 b = a[1]。NumPy在幕后执行了以下操作:
-
计算偏移量 :
offset = 1 * a.strides[0] = 24字节。 -
设置数据指针 :让视图
b.data指向a.data起始地址向后偏移24字节的位置。 -
设置新元数据 :
b.shape = (3,),b.strides = (8,)。
这样,b就完全没有复制数据,而是通过自己独立的shape和strides,配合指向原始内存区的指针,正确地"解码"出了我们需要的那一维数据。
比较
| 数据类型/方法 | 切片行为 | 是否复制 | 备注 |
|---|---|---|---|
普通列表 list |
lst[a:b] |
是,创建新列表(浅拷贝) | 通用但消耗内存 |
array.array |
arr[a:b] |
是,也创建新数组(复制元素) | 比列表紧凑,但仍复制 |
memoryview |
mv[a:b] |
否 ,返回新 memoryview 视图 |
需要对象支持缓冲区协议(如 bytes, bytearray, array.array) |
numpy.ndarray |
narr[a:b] |
否(默认返回视图) | 科学计算常用,支持多维 |
itertools.islice |
islice(seq, a, b) |
否,返回迭代器 | 不复制,但只能顺序访问一次,不支持索引 |
在这些方法里,需要额外安装 的只有 numpy。其他提到的模块和方法都是 Python 自带的,无须额外安装。
-
NumPy (
numpy) :需要额外安装。它是 Python 的科学计算第三方库,通常在命令行中使用pip install numpy来安装。 -
array模块 :是 Python 的标准库模块,直接import array即可使用。 -
memoryview:是 Python 的一个内置类型,直接使用。 -
itertools模块 :是 Python 的标准库模块,直接import itertools即可使用。 -
copy模块 :是 Python 的标准库模块,直接import copy即可使用。 -
slice对象:是 Python 的一个内置类型,直接使用。
2.追加 ------ 动态扩容的艺术
1.append 的过度扩容(overallocation)机制
列表在 CPython 中是一个长度可变的数组。当 append 触发扩容时,并不是只增加一个元素的空间,而是多分配一些备用容量 ,以减少未来扩容的次数。策略大约是:new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6),即每次扩容约 12.5% 的额外空间。(跟版本也有关系)
import sys
lst = []
print(sys.getsizeof(lst)) # 56 字节(空列表开销)
for i in range(10):
lst.append(i)
print(f"len={len(lst)}, size={sys.getsizeof(lst)}")
-
ys.getsizeof返回对象占用内存字节数(仅容器本身,不包括内部元素对象)。 -
空列表初始大小(例如56字节)取决于 Python 版本/实现。
-
列表动态扩容:当向列表追加元素时,如果当前容量不足,Python 会重新分配更大的内存,导致
getsizeof返回值增加。 -
扩容策略(如过量分配)以避免每次追加都重新分配。
-
注意
getsizeof不递归计算元素大小,只是列表对象的内存(包括底层数组的指针数组)。
还需要指出输出模式:开始56,然后随着 len 增加,size 会阶梯式增长。
会发现列表的 __sizeof__ 有时会跳跃式增长。理解这一点有助于评估内存占用,尤其在大量数据时。
2. extend 与 += 的细微区别
lst.extend(iterable) 和 lst += iterable 效果相同,都是原地修改。但 + 运算符(如 lst = lst + iterable)会创建一个新列表,效率较低且改变了引用。所以在大列表上使用 += 而不是 + 是一个重要优化。
另外,extend 可以接受任何可迭代对象,而 + 强制要求左右都是列表(或者左列表,右可迭代?实际上 lst + [1,2] 是合法的,但 lst + (1,2) 会报错)。extend 更通用。
iterable 是 Python 中的一个概念,指的是任何可以返回其元素(一次一个)的对象 ,例如列表、元组、字符串、字典(迭代键)、range 对象、生成器等。它要求对象实现了 __iter__() 方法或 __getitem__() 方法,从而可以通过 for...in 循环遍历。
3. 预先分配容量以减少扩容
如果你要添加大量元素并且知道大致数量,可以预先创建列表并赋值,而不是反复 append。
# 不推荐
lst = []
for i in range(N):
lst.append(i)
# 推荐
lst = [0] * N
for i in range(N):
lst[i] = i
第二种方式避免了多次扩容,通常更快。但需要注意,如果元素是可变对象,[[]]*N 会产生共享引用的陷阱。
4. append 与列表推导式的取舍
列表推导式不仅更简洁,而且内部实现使用专门的字节码,比显式循环 append 快:
# 慢
squares = []
for x in range(1000):
squares.append(x**2)
# 快
squares = [x**2 for x in range(1000)]
这是因为列表推导式避免了每次循环中对 append 方法的查找和调用开销,并且底层直接构建数组。
3. 插入 ------ 成本高昂的灵活
1. insert 的时间复杂度与内存移动
insert(i, v) 需要将 i 之后的元素全部向后移动一位,平均时间复杂度 O(n)。对于大型列表,在开头或中间频繁插入会非常慢。在 CPython 中,移动元素是通过 memmove 完成的,虽然 C 级别很快,但数据量大时仍然有显著开销。
2. 批量插入:切片赋值 vs 多次 insert
假设要在索引 p 处插入 k 个元素,使用 lst[p:p] = [x1, x2, ..., xk] 只需一次内存移动(移动 len(lst)-p 个元素),而每次 insert 都会移动一次,总复杂度 O(k * n)。因此,切片赋值是批量插入的首选。
3. 在头部插入的替代方案:collections.deque
如果你需要频繁在列表两端添加或删除,应该使用 deque(双端队列),它在两端操作都是 O(1)。
append和extend是list类型的方法,也是deque类型的方法(但实现不同)。
appendleft和extendleft是deque类型特有的方法,因为只有双端队列才高效支持左侧操作。这些方法都内建在相应的类中(即它们是内建类型的实例方法),但不是像
from collections import deque
dq = deque([2, 3, 4])
dq.appendleft(1) # O(1)
dq.extendleft([0, -1]) # 注意:extendleft 会将参数逆序插入到左侧
print(dq) # deque([-1, 0, 1, 2, 3, 4])
deque 也支持 insert,但它的 insert 仍然是 O(n)(因为需要内部移动)。所以大量任意位置插入仍不适合。
4. 插入的实用模式:维持有序列表
当你需要维护一个升序列表并且不断插入新元素时,直接使用 list.insert 虽然可以,但每次 O(n)。对于大量插入,更好的数据结构是 bisect 模块 + 列表(查找 O(log n),插入 O(n))或使用 heapq(堆)或者 sortedcontainers(第三方库)。
import bisect
lst = [10, 20, 30]
bisect.insort(lst, 25) # 插入后保持有序
bisect.insort 内部也是用 insert 实现的,但至少帮你找到正确位置。
4.性能基准与实战建议
1. 简单基准对比
使用 timeit 模块可以直观感受不同操作的速度差异
import timeit
from collections import deque
N = 100000
# 测试 append(每次动态扩容)
print(timeit.timeit('for i in range(N): lst.append(i)',
setup='lst = []',
globals=globals(),
number=10))
# 测试预分配后赋值(修正)
print(timeit.timeit('for i in range(N): lst[i] = i',
setup='lst = [0]*N',
globals=globals(),
number=10))
# 测试列表头部插入 O(n)
print(timeit.timeit('lst = list(range(1000)); lst.insert(0, -1)',
number=1000))
# 测试 deque 头部追加 O(1)
print(timeit.timeit('dq = deque(range(1000)); dq.appendleft(-1)',
setup='from collections import deque',
number=1000))
通常预分配比动态 append 快 10-20%;头部插入列表比 deque 慢几个数量级。
2. 实战建议汇总
| 场景 | 推荐做法 |
|---|---|
| 尾部添加单个元素 | append |
| 尾部添加多个元素(已知) | extend 或 += |
| 尾部添加多个元素(通过生成器迭代) | extend 或循环 append(后者稍慢但可接受) |
| 头部或中间添加单个元素 | insert,但注意性能;如果频繁操作,考虑 deque |
| 头部或中间添加多个元素 | 切片赋值 lst[pos:pos] = items |
| 需要快速查找并插入有序列表 | bisect.insort |
| 频繁从两端操作 | deque |
| 需要大量数值运算且关注内存 | array 或 numpy 数组(支持切片视图) |
| 需要惰性切片避免复制 | itertools.islice |
5.扩展:自定义序列实现切片逻辑
如果你定义自己的类并希望支持切片,可以实现 __getitem__ 和 __setitem__ 方法,并处理 slice 对象。
class MyList:
def __init__(self, data):
self.data = data[:]
def __getitem__(self, key):
if isinstance(key, slice):
return MyList(self.data[key])
else:
return self.data[key]
def __setitem__(self, key, value):
if isinstance(key, slice):
self.data[key] = value
else:
self.data[key] = value
这样就可以使用 obj[1:3] 等操作。
6.高级陷阱与细节
1. 对同一个列表的多个切片同时赋值
lst = [0, 0, 0, 0]
lst[::2] = [1, 2] # 索引 0,2 变成 1,2
lst[1:3] = [3] # 注意此时列表长度变化
顺序很重要,因为切片赋值会改变列表长度,影响后续切片的索引。尽量避免相互依赖的切片操作同时进行,除非你明确知道顺序后果。
2. 空切片与边界情况
lst[5:5] = some在索引 5 处插入(不会删除)。
lst[5:4] = ...呢?如果start > stop且步长为正,切片为空,赋值会从左端点开始?实际上,当步长为正时,start必须小于等于stop,否则切片为空且插入位置由start决定(即start处)。这有点反直觉,建议避免使用。
lst = [1,2,3]
lst[2:1] = [4] # 在索引 2 处插入,因为空切片位置是 start=2
print(lst) # [1,2,4,3]
这种行为有一定规律,但最好显式使用 lst[start:start]。
3. list 与 array 的切片复制效率
array 模块的切片返回的新 array 也是复制内存,但如果你使用 memoryview,可以做到零复制视图(只对支持缓冲协议的对象,如 bytes、bytearray、array)。对于普通列表没有直接视图支持。
import array
a = array.array('i', range(1000))
mv = memoryview(a)
slice_view = mv[100:200] # 视图,不复制数据
print(slice_view[0]) # 访问第一个元素
感谢你的观看,期待我们下次再见!