Python(列表进阶)
目录1.切片---不只是一段子列表1.切片的内存模型浅拷贝与共享2. 切片的实现细节slice 对象与 __getitem__3. 步长为负的彻底理解4. 切片赋值的高级技巧5. 切片作为视图memoryview 与 array 模块为什么切片会创建副本如何避免创建副本对于不可变序列如字符串、元组呢NumPy视图理解“视图”数据共享而非复制“视图”的核心优势与应用场景视图的风险意外修改与内存泄漏1. 数据被意外修改 (数据泄漏)2. 可能阻止内存释放 (内存泄露)核心区别视图 vs 副本 (View vs Copy)创建视图的注意事项视图的实现原理比较2.追加 —— 动态扩容的艺术1.append 的过度扩容overallocation机制2. extend 与 的细微区别3. 预先分配容量以减少扩容4. append 与列表推导式的取舍3. 插入 —— 成本高昂的灵活1. insert 的时间复杂度与内存移动2. 批量插入切片赋值 vs 多次 insert3. 在头部插入的替代方案collections.deque4. 插入的实用模式维持有序列表4.性能基准与实战建议1. 简单基准对比2. 实战建议汇总5.扩展自定义序列实现切片逻辑6.高级陷阱与细节1. 对同一个列表的多个切片同时赋值2. 空切片与边界情况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获得迭代器避免内存复制。NumPyNumPy 不是 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。首先看一下原始数组的信息pythonimport 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配合指向原始内存区的指针正确地“解码”出了我们需要的那一维数据。比较数据类型/方法切片行为是否复制备注普通列表listlst[a:b]是创建新列表浅拷贝通用但消耗内存array.arrayarr[a:b]是也创建新数组复制元素比列表紧凑但仍复制memoryviewmv[a:b]否返回新memoryview视图需要对象支持缓冲区协议如bytes,bytearray,array.arraynumpy.ndarraynarr[a:b]否默认返回视图科学计算常用支持多维itertools.isliceislice(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(flen{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类型特有的方法因为只有双端队列才高效支持左侧操作。这些方法都内建在相应的类中即它们是内建类型的实例方法但不是像print那样的内置函数。可以说它们是“内置类型的方法”。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), setuplst [], globalsglobals(), number10)) # 测试预分配后赋值修正 print(timeit.timeit(for i in range(N): lst[i] i, setuplst [0]*N, globalsglobals(), number10)) # 测试列表头部插入 O(n) print(timeit.timeit(lst list(range(1000)); lst.insert(0, -1), number1000)) # 测试 deque 头部追加 O(1) print(timeit.timeit(dq deque(range(1000)); dq.appendleft(-1), setupfrom collections import deque, number1000))通常预分配比动态append快 10-20%头部插入列表比 deque 慢几个数量级。2. 实战建议汇总场景推荐做法尾部添加单个元素append尾部添加多个元素已知extend或尾部添加多个元素通过生成器迭代extend或循环append后者稍慢但可接受头部或中间添加单个元素insert但注意性能如果频繁操作考虑deque头部或中间添加多个元素切片赋值lst[pos:pos] items需要快速查找并插入有序列表bisect.insort频繁从两端操作deque需要大量数值运算且关注内存array或numpy数组支持切片视图需要惰性切片避免复制itertools.islice5.扩展自定义序列实现切片逻辑如果你定义自己的类并希望支持切片可以实现__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 处插入因为空切片位置是 start2 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]) # 访问第一个元素感谢你的观看期待我们下次再见