Python(列表进阶)

目录

1.切片---不只是一段子列表

1.切片的内存模型:浅拷贝与共享

[2. 切片的实现细节:slice 对象与 getitem](#2. 切片的实现细节:slice 对象与 getitem)

[3. 步长为负的彻底理解](#3. 步长为负的彻底理解)

[4. 切片赋值的高级技巧](#4. 切片赋值的高级技巧)

[5. 切片作为视图:memoryview 与 array 模块](#5. 切片作为视图:memoryview 与 array 模块)

为什么切片会创建副本?

如何避免创建副本?

对于不可变序列(如字符串、元组)呢?

NumPy

视图

理解"视图":数据共享,而非复制

"视图"的核心优势与应用场景

视图的风险:意外修改与内存泄漏

[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. 插入的实用模式:维持有序列表)

4.性能基准与实战建议

[1. 简单基准对比](#1. 简单基准对比)

[2. 实战建议汇总](#2. 实战建议汇总)

5.扩展:自定义序列实现切片逻辑

6.高级陷阱与细节

[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. 切片作为视图:memoryviewarray 模块

切片操作(如 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
使用 arraynumpy 对于数值数组,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.baseNone:说明这个数组自己拥有数据,是原始数组。

  • 如果 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.baseNone

创建视图的注意事项

  • 当切片是不连续时 :使用"高级索引"(如整数数组索引)时,NumPy很可能会生成一个副本,而不是视图。

  • 当切片导致形状改变时:某些形状的改变也可能导致生成副本。

视图的实现原理

NumPy实现视图的核心机制包括 datashapestrides

首先看一下原始数组的信息:

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在幕后执行了以下操作:

  1. 计算偏移量offset = 1 * a.strides[0] = 24 字节。

  2. 设置数据指针 :让视图 b.data 指向 a.data 起始地址向后偏移 24 字节的位置。

  3. 设置新元数据b.shape = (3,)b.strides = (8,)

这样,b就完全没有复制数据,而是通过自己独立的shapestrides,配合指向原始内存区的指针,正确地"解码"出了我们需要的那一维数据。

比较

数据类型/方法 切片行为 是否复制 备注
普通列表 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)。

appendextendlist 类型的方法,也是 deque 类型的方法(但实现不同)。

appendleftextendleftdeque 类型特有的方法,因为只有双端队列才高效支持左侧操作。

这些方法都内建在相应的类中(即它们是内建类型的实例方法),但不是像 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)',
                    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
需要大量数值运算且关注内存 arraynumpy 数组(支持切片视图)
需要惰性切片避免复制 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. listarray 的切片复制效率

array 模块的切片返回的新 array 也是复制内存,但如果你使用 memoryview,可以做到零复制视图(只对支持缓冲协议的对象,如 bytesbytearrayarray)。对于普通列表没有直接视图支持。

复制代码
import array
a = array.array('i', range(1000))
mv = memoryview(a)
slice_view = mv[100:200]   # 视图,不复制数据
print(slice_view[0])       # 访问第一个元素

感谢你的观看,期待我们下次再见!

相关推荐
27669582921 小时前
阿里最新acw_sc__v2 分析
开发语言·python·acw_sc__v2·acw_sc__v2逆向·acw_sc__v2算法·acw_sc__v2算法分析·cookie逆向
vortex52 小时前
python 库劫持:原理、利用与防御
python·网络安全·提权
QYQ_11272 小时前
嵌入式学习——杂项设备、Platform总线和设备树源文件
学习
捉鸭子2 小时前
某音a_bogus vmp逆向
爬虫·python·web安全·node.js·js
曲幽3 小时前
FastAPI 生产环境静态文件完全指南:从 /favicon.ico 404 到 HSTS 混合内容,一次全根治
python·fastapi·web·static·media·404·hsts·favicon·url_for
Dontla3 小时前
Python asyncpg库介绍(基于Python asyncio的PostgreSQL数据库驱动)连接池、SQLAlchemy
数据库·python·postgresql
zh1570233 小时前
如何编写动态SQL存储过程_使用sp_executesql执行灵活查询
jvm·数据库·python
2401_824222693 小时前
SQL报表统计数据量巨大_分批统计策略
jvm·数据库·python
X56613 小时前
mysql如何处理连接数过多报错_调整max_connections参数
jvm·数据库·python