AI 技术博客
返回首页
Python 基础 19 分钟阅读

列表:序列操作与常用方法

Python列表序列操作排序

引言

第六章正式进入容器(container)的王国。前五章我们处理的数据基本是单一对象:一个数字、一个字符串。但真实程序处理的是批量数据——一天的日志有几十万行,一张成绩单有几十个分数,一次接口返回几百条记录。本章从列表(list)开始,讲 Python 最常用的三种内置容器:列表、元组与字典,随后是集合、拷贝语义与嵌套结构,最后两篇实战把它们组合起来解决词频统计与聚合报表问题。

本篇是本章第一篇,解决三个问题:列表的序列操作(索引、切片、拼接、重复、成员测试)、列表的常用方法(增删改查的九个方法)、以及 sort 与 sorted 的取舍。先决知识:第五章第 2 篇已经把切片语法 s[start:stop:step] 讲透了——含头不含尾、负索引、负步长、越界钳制,本篇的列表切片与字符串切片规则完全一致,直接复用,不再重复定义。两者的差异(可变性、切片赋值、切片返回新列表)才是本篇的新内容。

概念与原理

列表是什么:可变的、有序的、异构的序列

列表是一个有序的容器:元素按插入顺序排列,每个元素可以通过下标访问。定义上它是可变(mutable)的:创建之后可以增、删、改其中的元素,这是它与字符串的根本分野——字符串不可变,任何”修改”都是重建新串;列表可以在原地动手术。列表还是异构(heterogeneous)的:一个列表可以混装整数、字符串、甚至其他列表,因为 Python 变量本身就是引用,列表里存的是对象的引用而不是对象本体,就像一排插槽,每个槽指向一个对象,槽里放什么类型都没关系。

理解”存引用”是本章的地基:lst[0] 取的是第一个槽里的引用;lst[1] = x 是把第二个槽指向 x,不触碰旧对象;两个列表变量指向同一列表时,改一个另一个也会看到变化(别名问题第五章第六篇提过,第六章第 5 篇会系统讲拷贝与引用)。

底层是动态数组,行为由它决定

CPython 中列表的底层实现是动态数组(dynamic array):一段连续的内存槽,每个槽 8 字节(64 位平台上),存一个对象指针。连续存放意味着按下标访问是 O(1) 的——只需算 基地址 + 下标 * 8,一次内存寻址。数组容量不够时会整体搬迁到更大的新数组(预留 1/8 左右冗余),所以末尾追加均摊下来也是 O(1);而头部插入(insert(0, x))要把后面所有元素整体后移,是 O(n);按值删除(remove)要先线性扫描找到位置,同样是 O(n)。至于成员测试 x in lst,在没有其他信息时只能逐个比较,是 O(n)。

这一组复杂度结论不是理论摆设,它直接决定工程选型:需要频繁在下标 0 处插入/删除时,list 不是好选择(后续课程会讲 collections.deque);需要频繁判断”某个值在不在”时,O(n) 的扫描太慢,第六章第 3 篇的字典把这个问题降到 O(1)。现在先把结论记住,理由会在本篇与第 3 篇中分别展开。

与字符串的异同:同一个序列协议,两套可变性

第五篇的字符串和本篇的列表共享序列协议(sequence protocol):len() 取长度、s[i] 下标访问、s[a:b] 切片、x in s 成员测试、s + t 拼接、s * n 重复,行为完全一致。正因为协议统一,第五章第 2 篇学的切片规则(含头不含尾、负索引、负步长反转、越界静默钳制)在列表上一个字都不用改。

差异集中在三个地方。第一,可变性:字符串不能 s[0] = "x",列表可以 lst[0] = x,还能用切片赋值一次替换一段,用 del 删除一段。第二,切片产物:字符串切片返回新字符串(str 不可变,只能新建);列表切片也返回新列表,但新列表里的元素是原元素的引用拷贝——「新瓶装旧酒」,这就是浅拷贝的雏形,第 5 篇会解剖。顺带一提,第五章第 2 篇实测过 CPython 对字符串全切片 s[:] 有返回原对象的优化路径,列表没有这条路径,lst[:] is lst 恒为 False,每次全切片都实打实复制一个数组。第三,切片赋值:lst[1:3] = [...] 是字符串没有的操作,它不仅能替换,还能改变列表长度,是批量修改的核心工具。

操作与实现

序列操作五件套:索引、切片、拼接、重复、成员测试

scores = [88, 93, 77, 56, 91]
print(len(scores))
print(scores[0], scores[-1])
print(93 in scores, 100 in scores)
print(scores[1:4])
print(scores[::-1])
print([0] * 3)
print([1, 2] + [3, 4])
5
88 91
True False
[93, 77, 56]
[91, 56, 77, 93, 88]
[0, 0, 0]
[1, 2, 3, 4]

逐一解读:scores[-1] 是倒数第一个元素(负索引换算规则同字符串);93 in scores 做一次 O(n) 线性扫描返回 True;scores[1:4] 含头不含尾,取出下标 1、2、3;scores[::-1] 是负步长反转,返回新列表而不动原表——这与字符串的 s[::-1] 完全同构,第五章第 2 篇的边界规则在这里逐条成立(比如 scores[:3:-1] 同样是空列表,start 缺省到表尾与 stop 重合)。[0] * 3 生成三个 0 的列表,[1, 2] + [3, 4] 首尾拼接生成新列表;两者都不修改操作数。

元素修改:下标赋值、切片赋值与切片删除

temps = [31, 33, 28, 35]
temps[1] = 32
print(temps)
temps[2:4] = [29, 34, 30]
print(temps)
del temps[0]
print(temps)
del temps[1:3]
print(temps)
try:
    "abc"[1] = "X"
except TypeError as err:
    print(err)
[31, 32, 28, 35]
[31, 32, 29, 34, 30]
[32, 29, 34, 30]
[32, 30]
'str' object does not support item assignment

temps[1] = 32 是单元素赋值。temps[2:4] = [29, 34, 30] 是切片赋值:把下标 2、3 两个槽替换成三个新元素,长度从 4 变成 5——替换段的长度不必相等,这是字符串做不到的(第五章第 2 篇演示过 long[1:3] = "xx" 抛 TypeError)。del temps[0] 删单个元素,del temps[1:3] 删除一段连续区域,列表自动前移填补。最后一行对照字符串:报错信息 'str' object does not support item assignment 与第五章第 2 篇实测完全一致。注意下标赋值与切片赋值的边界规则与切片读取一致:越界同样静默钳制——temps[2:4] = [] 会原地删除一段而不报错。

切片返回新列表:新瓶装旧酒

nums = [1, 2, 3, 4]
part = nums[1:3]
part.append(99)
print(nums)
print(part)
print(nums[:] is nums)
[1, 2, 3, 4]
[2, 3, 99]
False

part = nums[1:3] 得到独立的新列表,此后对 part 的任何修改(这里 append 了 99)都不影响 nums。nums[:] is nums 为 False 说明每次切片都是新复制,这与字符串全切片的优化路径不同(第五章第 2 篇实测 s is s[:] 为 True,属实现细节、规范不保证)。但要注意「独立」是有边界的:若元素本身是可变对象(如嵌套列表),切片复制的是引用,共享同一批内层对象——修改内层对象会两边同时可见,这正是第六章第 5 篇「浅拷贝 vs 深拷贝」的预告。

常用方法:append / extend / insert / remove / pop / clear

tasks = ["写作", "复习", "练习"]
tasks.append("测验")
print(tasks)
tasks.extend(["检查", "提交"])
print(tasks)
tasks.insert(1, "校对")
print(tasks)
item = tasks.pop()
print("弹出:", item)
print(tasks)
tasks.remove("复习")
print(tasks)
print(tasks.index("测验"))
print(tasks.count("测验"))
['写作', '复习', '练习', '测验']
['写作', '复习', '练习', '测验', '检查', '提交']
['写作', '校对', '复习', '练习', '测验', '检查', '提交']
弹出: 提交
['写作', '校对', '复习', '练习', '测验', '检查']
['写作', '校对', '练习', '测验', '检查']
3
1

六个方法各司其职:append(x) 末尾追加一个元素,均摊 O(1);extend(iterable) 把另一个可迭代对象(列表、元组、字符串、range 都行)的元素逐个并入,等价于循环 append 但更高效;insert(i, x) 在下标 i 处插入,其后的元素整体后移,O(n);pop() 无参时弹出并返回末尾元素,pop(i) 弹出下标 i 处元素;remove(x) 按值删除第一个匹配项;clear() 清空全部元素。index(x) 返回第一个匹配的下标,count(x) 统计出现次数,这两个是查询方法,不修改列表。用任务清单打比方:append 是「任务入队」,pop 是「完成出队」,remove 是「取消某项」,正好是到点队列的三类操作。

排序与反转:reverse / copy 及其取舍

deck = [1, 2, 3, 4, 5]
deck.reverse()
print(deck)
src = [1, 2, 3]
dst = src.copy()
dst.append(4)
print(src)
print(dst)
print(src == dst)
[5, 4, 3, 2, 1]
[1, 2, 3]
[1, 2, 3, 4]
False

reverse() 原地反转,不返回新表(返回 None);要「反转副本」就写 deck[::-1]。copy() 返回浅拷贝新列表(等价于 src[:]),复制后 dst.append(4) 不影响 src;src == dst 比较的是元素内容而非身份,同为 False——内容不同。== 与 is 的分工在第五章第 2 篇讲过,此处复用:值比较用 ==,身份比较用 is。

sort 与 sorted:原地排序对新建排序

words = ["python", "Go", "Java", "rust"]
print(sorted(words))
print(sorted(words, key=len))
print(sorted(words, key=str.lower))
words.sort(reverse=True)
print(words)
['Go', 'Java', 'python', 'rust']
['Go', 'Java', 'rust', 'python']
['Go', 'Java', 'python', 'rust']
['rust', 'python', 'Java', 'Go']

四行逐一解读。默认为 sorted(words):按字符码点比较,大写字母(65-90)整体排在小写字母(97-122)之前,所以 ‘Go’、‘Java’(大写 G、J 开头)排在 python、rust(小写开头)前面;同为小写开头的 python 比 rust 小。key=str.lower 把每个词先转小写再比,‘python’ 依然小于 ‘rust’,但注意此时比较键是 python、go、java、rust 四个小写串,结果排序仍然把 ‘Go’、‘Java’ 放前面;key=len 按长度比(6、2、4、4 对应 python、Go、Java、rust),同样长度时保持输入顺序——这就是稳定排序(stable sort):键值相等的元素维持原先的相对先后(Java 在 rust 之前,与输入一致)。最后 words.sort(reverse=True) 原地降序:默认升序结果 ['Go', 'Java', 'python', 'rust'] 整体翻转后,‘Go’(大写 G 的码点 71 大于小写 r 的 114)恰好落到底部。

nums = [3, 1, 2]
result = nums.sort()
print(result, nums)
None [1, 2, 3]

sort() 是原地排序,修改列表本身并返回 None;sorted() 是新建排序,不动原序列,返回新列表。选择依据只有一条:还要不要保留原顺序。要保留(比如先排一个副本,原数据用于别处),用 sorted(lst);确定原序再无用处、且列表很大时,用 lst.sort() 省一次 O(n) 复制。第三种姿势是 sorted(..., key=...) 给排序「打分」:key 函数对每个元素只调用一次,取返回值作为比较键,比逐个比较元素本身高效,也允许按任意维度排——按字符串长度、按字典的 items() 结果、按嵌套结构中的某个字段(lambda 表达式在第四章学过),第 9 篇聚合报表会大量使用。

易错点与陷阱

陷阱一:remove 删除永远停在第 1 个匹配,找不到就抛异常

names = ["zhang", "li", "wang"]
names.remove("li")
print(names)
try:
    names.remove("zhao")
except ValueError as err:
    print(err)
empty = []
try:
    empty.pop()
except IndexError as err:
    print(err)
['zhang', 'wang']
list.remove(x): x not in list
pop from empty list

remove 只删第一个匹配项:列表 [1, 2, 1] 调 remove(1) 剩 [2, 1],想删光所有 1 必须循环到 count 为 0(或改用第 7 篇的 Counter 思路)。元素不存在时抛 ValueError,空列表 pop() 抛 IndexError——删除类操作失败是异常不是静默,批量清理时要么先判 in,要么用 try/except 兜住,别让单个缺失中断整批处理(第五章第 8 篇的坏行防御同样的思路)。

陷阱二:把 sort 的返回值当排序结果

刚实测 nums.sort() 返回 None——如果写下 sorted_nums = nums.sort(),sorted_nums 存的就是 None 而不是排序结果。常见翻车现场:data = data.sort() 后 data 变成 None,后续遍历直接 TypeError。规矩只有一条:要用新列表就写 sorted(data),要原地就写 data.sort(),绝不把后者的返回值赋回去。同理 reverse() 返回 None,append/extend/insert 也返回 None——可变对象的方法约定俗成「修改自身、不返回新值」,把返回值赋给变量前先想想它是否真的是「新建」。

陷阱三:[x] * n 复制的是引用,可变元素会共享

matrix = [[0]] * 3
matrix[0].append(1)
print(matrix)
[[0, 1], [0, 1], [0, 1]]

[[0]] * 3 生成的是三个指向同一个内层列表的槽:matrix[0].append(1) 只改了一个,却显示三处都变了。对不可变元素(数字、字符串)* 完全安全,因为共享的对象不可变、改不了;对可变元素就是连环车祸。要三份独立内层列表,用列表推导式:[[0] for _ in range(3)](第三章学过推导式)。完整的引用与拷贝机制(浅拷贝、深拷贝、copy 模块)是第六章第 5 篇的主题,这里先记住症状,原理到那篇再解剖。

陷阱四:混合类型排序与 insert 越界的「宽容」

try:
    sorted([3, "x"])
except TypeError as err:
    print(err)
'<' not supported between instances of 'str' and 'int'

数字和字符串没法比大小,sorted 直接抛 TypeError——列表异构是把双刃剑,排序前先确认元素类型一致。另一处「宽容」:insert 的下标越界不报错,超长就插到末尾(items.insert(99, 3) 得到 [1, 2, 3])。这不是 bug 而是刻意的钳制语义(与切片越界钳制一脉相承),但写代码时别指望靠 insert 越界报错来发现 bug——需要报错时用下标赋值,越界的下标赋值会抛 IndexError。

小结

列表是可变的动态数组:索引与切片 O(1)、成员测试与头部插入 O(n)、尾部追加均摊 O(1);序列操作(索引、切片、拼接、重复、成员测试)与字符串共用一套协议,第五章第 2 篇的切片规则原样复用,差异只在「可变」:下标赋值、切片赋值(可改长度)、del 删除,以及切片恒返回新列表。九大方法按职责分组:追加族 append/extend/insert,删除族 pop/remove/clear,查询族 index/count,排序与拷贝 sort/reverse/copy。sort 原地返回 None,sorted 新建返回列表,支持 key 与 reverse,两者都是稳定排序。四个陷阱记住了再动手:remove 只删第一个且找不到报 ValueError、可变方法返回 None、[x] * n 共享可变元素、混合类型不能排序。下一篇《元组》会看到同一套序列协议下不可变版本的长处,以及解包这把钥匙怎么用。

练习与思考题

  1. 不运行代码,写出下列表达式的结果并逐条解释,再上机验证:[10, 20, 30, 40][::-2]、[1, 2, 3] + [4] * 2、"ab" in ["ab", "cd"]、[x for x in [5, 2, 8, 2] if x > 4]。
  2. 写出两个等价的「删除列表全部偶数」实现:一个用 while + remove 循环,一个用列表推导式重建。比较两者在 [1, 2, 2, 3, 2] 上的结果,说明为什么 for i in lst: lst.remove(i) 会漏删(提示:删除会让后面的元素前移,迭代下标却继续前进)。
  3. 工程题:有一份成绩列表 grades = ["A", "B", "A", "C", "B", "A"],用 count 与 sorted(配 key 参数)输出「按 A、B、C 的字母顺序排列后的去重列表」;再想想如果要求按出现次数从多到少排,key 该怎么写(允许使用之前学过的任何表达式)。
  4. 思考题:为什么 lst[1:3] = [] 能「原地删除一段」而 lst[1:3] 却得到一个新列表?从「切片是一个表达式,赋值是一个语句」的角度分析两者在内存上的差别。