列表
List
单个变量存不下一批数据。列表是 Python 里最常用的容器,也是第一个可以原地修改的类型;几个名字共用一个列表时,会出现一些初看很意外的现象。
本讲结束后你应当能
- 用列表存储和处理一批数据,而不是定义一百个变量
- 说清楚列表在内存里存的是什么,并用 id() 验证
- 分辨「修改一个列表」和「让名字改指另一个列表」
- 避开浅拷贝、用乘号造矩阵、可变默认值、边遍历边删除这几个常见错误
- 对常见列表操作的快慢有直观判断
本页目录
从一百个变量说起
假设要统计一个班 116 名学生的成绩:平均分、最高分、有多少人不及格。
用前几讲学过的东西,只能这样写:
score1 = 87
score2 = 92
score3 = 65
# …还有 113 个
求平均分,要把 116 个变量名逐个写出来相加;要求最高分,得写一长串比较;下学期变成 120 人,整段代码都要改。
更根本的问题是:变量名在写代码时就要定下来,而数据有多少个,往往要到运行时才知道,比如文件里有多少行、用户会输入多少个数。所以需要一种新的东西:能把一批数据放在一起,用序号而不是名字去访问。
这就是列表。
scores = [87, 92, 65, 78, 95]
print("人数:", len(scores))
print("总分:", sum(scores))
print("平均:", sum(scores) / len(scores))
print("最高:", max(scores))
五个成绩和五十个成绩,这段代码一个字都不用改。
列表是一种数据结构:一种组织数据的方式,加上针对它的一组操作。组织方式决定了哪些操作快、哪些操作慢。这门课讲四种常用的数据结构:列表、元组、字典、集合,今天是第一种。
创建与访问
列表用方括号创建,元素之间用逗号隔开。元素可以是任何类型,类型也可以不一致:
a = [1, 3, 9, 7] # 四个整数
b = ["Hello", "world"] # 两个字符串
c = [1, "two", 3.0, True] # 混合类型,合法
d = [] # 空列表
print(a, b, c, d)
print(type(a))
也可以用 list() 把其他能逐个取出元素的东西转成列表。这类东西统称可迭代对象,for 后面的 in 能接的,就是这一类:
print(list("hello")) # 字符串 → 逐个字符
print(list(range(5))) # range → 整数序列
下标从 0 开始
访问用方括号加下标,和 L3 取字符串里的字符一样。第一个元素的下标是 0:
a = [10, 20, 30, 40, 50]
print(a[0]) # 第一个
print(a[4]) # 第五个,也是最后一个
print(a[-1]) # 负数从末尾往前数,-1 就是最后一个
print(a[-2]) # 倒数第二个
长度为 n 的列表,合法下标是 0 到 n-1。越界会报 IndexError:
a = [10, 20, 30]
print(a[3])
遍历
scores = [87, 92, 65, 78, 95]
for s in scores:
print(s, "及格" if s >= 60 else "不及格")
for s in scores 每一轮取到的是元素本身,不是下标。如果同时需要下标和元素,用 enumerate(),不要写 for i in range(len(scores)):
scores = [87, 92, 65]
for i, s in enumerate(scores):
print(f"第 {i + 1} 名学生:{s}")
成员判断
in 和 not in 判断某个值是否在列表里:
names = ["张三", "李四", "王五"]
print("李四" in names)
print("赵六" not in names)
列表在内存里是什么
列表里可以放任何类型的值,而且不管列表多长,按下标取值都一样快。这两点放在一起并不简单。
C、Java 里的数组是一段连续的内存,每一项类型相同、占的字节数相同,所以第 i 项的地址可以直接算出来:起始地址加上每项的大小乘以 i。这也是下标从 0 开始的原因:第 0 项的地址就是起始地址。
Python 的列表可以混放整数、字符串、别的列表,它们大小各不相同,这个公式就用不上了。Python 的做法是:列表的格子里存的不是数据本身,而是数据的地址。 L3 讲过,Python 的名字里存的是对象的地址;列表的每一格也一样。地址的大小是固定的,所以格子大小统一,按下标取值仍然很快;真正的数据放在别处,所以类型可以随意。
id() 可以验证这一点:
a = [1, 2, 3]
print("a 的 id: ", id(a))
print("a[0] 的 id:", id(a[0]))
列表是一个对象,它的第 0 项是另一个对象,两者的 id 不同。
两个名字,一个列表
L3 讲过:赋值复制的是地址,不是对象。 b = a 之后,a 和 b 指向同一个对象。对整数和字符串,这不会带来问题,因为它们不能修改。列表可以修改,情况就不一样了:
a = [1, 2, 3]
b = a # b 和 a 指向同一个列表
print("是同一个对象吗:", a is b)
b.append(4) # 通过 b 修改这个列表
print("a =", a) # a 也变了
print("b =", b)
a 和 b 是同一个列表的两个名字,这叫别名。通过 b 修改列表,从 a 也看得到。
修改与重新绑定
有两种看起来相似、实际完全不同的操作。
重新绑定:让名字改指一个新对象,原来的对象不受影响。
a = [1, 2, 3]
b = a
b = [9, 9, 9] # b 改为指向一个新列表
print("a =", a) # a 没变
print("b =", b)
原地修改:改的是对象本身,所有指向它的名字都看得到。
a = [1, 2, 3]
b = a
b[0] = 9 # 改的是列表内部
print("a =", a) # a 也变了
print("b =", b)
判断方法:等号左边只是一个名字,就是重新绑定;左边带了 [...],或者调用的是方法,就是原地修改。
把列表传进函数也是一样。参数名和调用者手里的名字指向同一个列表,函数里对它原地修改,调用者会看到;函数里给参数重新赋值,调用者不受影响。
增删改
列表是可变的,可以原地增删改。下面这些方法都是原地修改,返回 None:
| 方法 | 作用 |
|---|---|
a.append(x) | 在末尾追加一个元素 |
a.extend(it) | 把另一个可迭代对象的所有元素追加到末尾 |
a.insert(i, x) | 在下标 i 处插入 x,原有元素后移 |
a.remove(x) | 删除第一个值为 x 的元素,找不到报 ValueError |
a.pop(i) | 删除并返回下标 i 的元素,省略 i 时删最后一个 |
a.clear() | 清空 |
a.sort() | 原地排序 |
a.reverse() | 原地反转 |
del a[i] | 删除下标 i 的元素(这是语句,不是方法) |
pop 是例外:它会返回被取出的那个元素,因为这个值有用。
a = [3, 1, 2]
a.append(4) # [3, 1, 2, 4]
a.insert(0, 0) # [0, 3, 1, 2, 4]
a.remove(3) # [0, 1, 2, 4]
last = a.pop() # 取出 4,a 变成 [0, 1, 2]
a.sort()
print(a, "取出的是", last)
append 和 extend 的区别经常弄混:
a = [1, 2]
a.append([3, 4])
print("append:", a) # 整个列表作为一个元素放进去
b = [1, 2]
b.extend([3, 4])
print("extend:", b) # 把里面的元素逐个追加
a + b 也会逐个拼接,但它造一个新列表,a 本身不变。append 和 extend 修改原列表。
切片
列表的切片和 L3 字符串的切片规则一样,语法是 a[start:stop],含头不含尾:
a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(a[2:5]) # 下标 2、3、4
print(a[:3]) # 从头到下标 2
print(a[7:]) # 从下标 7 到末尾
print(a[:]) # 整个列表(得到一个新列表,后面有用)
print(a[-3:]) # 最后三个
第三个参数是步长:
a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(a[::2]) # 每隔一个取一个
print(a[1::2]) # 从下标 1 开始,每隔一个
print(a[::-1]) # 步长为负:倒序的新列表,不改原列表
列表可变,所以切片还可以放在等号左边,替换或删除一段:
a = [1, 2, 3, 4, 5]
a[1:3] = [20, 30] # 替换两项
print(a)
a[1:3] = [7, 7, 7, 7] # 长度可以不同
print(a)
a[1:5] = [] # 删除一段
print(a)
切片返回的是新列表:
a = [1, 2, 3]
b = a[:] # 切片产生新列表
print(a is b) # False,是两个不同的对象
b[0] = 99
print("a =", a) # a 不受影响
拷贝
想要一个独立的副本,有三种等价的写法:a[:]、a.copy()、list(a)。它们都造一个新列表。
但这只是浅拷贝:只复制最外层,内层的对象仍然是共用的。嵌套列表上会出问题:
a = [[1, 2], [3, 4]]
b = a.copy() # 浅拷贝
b[0][0] = 99 # 改的是内层列表
print("a =", a) # a 也变了
print("b =", b)
print("内层是同一个对象吗:", a[0] is b[0])
原因要回到列表存的是地址。a.copy() 造了一个新的外层列表,把 a 外层格子里的地址原样抄了过去。抄过去的是地址,所以两个外层列表的格子,指向的是同一组内层列表。b[0][0] = 99 修改的那个内层列表,a 也在用。
要连内层一起复制,用 copy.deepcopy()。它沿着每一个地址往下,把每一层都复制一份:
import copy
a = [[1, 2], [3, 4]]
b = copy.deepcopy(a)
b[0][0] = 99
print("a =", a) # 这次 a 不受影响
print("b =", b)
| 写法 | 复制几层 | 什么时候用 |
|---|---|---|
b = a | 不复制,只是别名 | 确实想让两个名字指同一个东西 |
b = a[:] 或 a.copy() | 只复制最外层 | 元素都是数、字符串这类不可变的值 |
copy.deepcopy(a) | 复制所有层 | 列表里还套着列表、字典等可变对象 |
深拷贝更慢,嵌套越深越慢。元素都是不可变的值时,浅拷贝就够了。
用乘号造矩阵
想造一个 3×3 的全零矩阵,很多人会这样写:
matrix = [[0] * 3] * 3
print(matrix) # 看起来完全正确
matrix[0][0] = 1
print(matrix) # 三行全变了
[[0] * 3] * 3 把 [0, 0, 0] 这一个列表的地址重复了三次,所以三行其实是同一个列表。
正确的做法是每一轮循环新造一行:
matrix = []
for _ in range(3):
matrix.append([0] * 3) # 每轮都是一个新列表
matrix[0][0] = 1
print(matrix) # 只有第一行变了
循环变量写成下划线 _,表示这个变量用不到。
可变默认值
L4 讲默认参数时说过,还有一个问题要等讲完列表再讲:
def add_item(item, box=[]):
box.append(item)
return box
print(add_item(1))
print(add_item(2)) # 本来应该是 [2]
第二次调用带着上一次留下的 1。那个空列表是在执行 def 那一行时创建的,整个程序里只创建一次。之后每次调用不传 box,box 指向的都是同一个列表。这又是几个名字共用一个可变对象。
正确写法是用 None 当默认值,进入函数后再新造一个列表:
def add_item(item, box=None):
if box is None:
box = []
box.append(item)
return box
print(add_item(1))
print(add_item(2))
默认值只用不可变的东西:数、字符串、None。
列表推导
前面好几次写过同一种循环:新建一个空列表,遍历另一组数据,逐个算出一个值 append 进去。Python 为这种写法提供了一种一行的形式:
[表达式 for 变量 in 可迭代对象 if 条件]
# 普通写法
squares = []
for i in range(10):
squares.append(i ** 2)
# 列表推导
squares2 = [i ** 2 for i in range(10)]
print(squares == squares2)
print(squares2)
从左往右念:对 range(10) 里的每一个 i,取 i 的平方,放进新列表。执行顺序是先 for,再判断 if,最后算表达式。
前面那个矩阵,用列表推导可以写成 [[0] * 3 for _ in range(3)]。
带条件的版本只保留满足条件的元素:
scores = [87, 92, 45, 78, 55, 95]
passed = [s for s in scores if s >= 60]
print("及格的:", passed)
print("及格率:", len(passed) / len(scores))
注意 if 的位置:写在 for 后面是过滤;写在最前面、配上 else,是 L4 的条件表达式,每一项都会放进结果,只是取值不同。
边遍历边删除
最后一个常见错误:在 for 循环里删除正在遍历的列表的元素。
a = [1, 2, 2, 3, 4]
for x in a:
if x == 2:
a.remove(x)
print(a) # 还剩一个 2
for 在内部记着现在走到了第几项。删掉第一个 2 之后,后面的元素整体前移一位,第二个 2 移到了刚删掉的位置上;循环却照常走向下一个位置,于是第二个 2 被跳过了。要删的元素不相邻时,碰巧不会出问题;一旦相邻就会漏掉。
正确做法是不在原列表上边遍历边改,而是用列表推导造一个新列表:
a = [1, 2, 2, 3, 4]
b = [x for x in a if x != 2]
print(b)
也可以遍历一份副本,在原列表上删除:for x in a[:]:。
操作的快慢
列表在内存里是连续排列的一排格子,每个格子存着一个元素的地址。这个结构决定了各种操作的快慢:
| 操作 | 快慢 | 原因 |
|---|---|---|
a[i] 按下标访问 | 快 | 直接算出格子的位置 |
a.append(x) | 快 | 在末尾加一格,不影响其他元素 |
a.pop() 删最后一个 | 快 | 同上 |
a.insert(0, x) | 慢 | 后面所有元素都要往后挪一格 |
a.remove(x) | 慢 | 先从头找到它,再把后面的往前挪 |
x in a | 慢 | 从头逐个比较 |
a.pop(0) 删第一个 | 慢 | 后面所有元素都要往前挪 |
这里的「慢」是指:列表越长越慢,而且和长度成正比。列表有一百万个元素时,a.insert(0, x) 要挪动一百万个位置。
复制整个列表时,切片 a[:] 比自己写循环逐项 append 快得多,因为切片在 Python 内部用 C 代码一次完成。但这只是常数倍的差别,两种写法所花的时间都和长度成正比。
小结
- 列表用下标访问一批数据,下标从 0 开始,
-1是最后一个 - 列表存的是地址。
b = a不复制列表,只是多了一个名字 - 修改(
b[0] = ...、b.append(...))影响所有别名;重新绑定(b = ...)不影响 - 原地修改的方法(
append/sort/reverse/extend)一律返回None - 切片
a[i:j]含头不含尾,返回新列表,越界不报错 a[:]和a.copy()是浅拷贝,嵌套结构要用copy.deepcopy()[[0] * n] * m造出的矩阵所有行是同一个列表,要每行新造- 默认值不用列表,用
None再在函数里新造 - 不要边遍历边删除
- 在末尾增删快,在开头增删和查找慢
练习
-
不用
max(),自己写一个函数求列表中的最大值。空列表时返回None。 -
写一个函数,去掉列表中的重复元素并保持原有顺序(不能用
set,它会打乱顺序)。 -
给定一个二维列表(矩阵),写一个函数返回它的转置。先用
[[0] * n] * m造结果矩阵试一次,看看会发生什么,再改对。 -
下面这段代码想把列表里所有偶数翻倍,但结果不对。找出原因并改对:
a = [1, 2, 3, 4, 5, 6] for i in range(len(a)): if a[i] % 2 == 0: a.insert(i, a[i] * 2) -
用
id()设计一个小实验,验证「切片产生新列表,但内层元素仍是共用的」。
本讲的配套上机题在 Lab 5。