CS1602计算导论
第 5 讲Part 2 数据的组织AI Level 0

列表

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 再在函数里新造
  • 不要边遍历边删除
  • 在末尾增删快,在开头增删和查找慢

练习

  1. 不用 max(),自己写一个函数求列表中的最大值。空列表时返回 None。

  2. 写一个函数,去掉列表中的重复元素并保持原有顺序(不能用 set,它会打乱顺序)。

  3. 给定一个二维列表(矩阵),写一个函数返回它的转置。先用 [[0] * n] * m 造结果矩阵试一次,看看会发生什么,再改对。

  4. 下面这段代码想把列表里所有偶数翻倍,但结果不对。找出原因并改对:

    a = [1, 2, 3, 4, 5, 6]
    for i in range(len(a)):
        if a[i] % 2 == 0:
            a.insert(i, a[i] * 2)
  5. 用 id() 设计一个小实验,验证「切片产生新列表,但内层元素仍是共用的」。

本讲的配套上机题在 Lab 5。