Skip to content

第 06 章 · 列表:存放一支电芯的循环容量 ​

这一章要解决什么:把一支电芯每一圈的容量按顺序存起来,并按"第几次循环"准确取出来。 这一章要打掉三个坑:下标从 0 开始、切片不含右端、b = a 不是复制。

本章你需要完成的代码:demos/ch06-list.py

6.1 为什么需要列表 ​

到第 05 章为止,一个变量只能装一个值:

python
cap1 = 2.50
cap2 = 2.46
cap3 = 2.42

如果一支电芯跑 800 圈,你不可能写 800 个变量名。列表(list)就是"一串值用一个名字装":

python
caps = [2.50, 2.46, 2.42, 2.38, 2.33]
print(len(caps))          # 5                     有几个?
print(caps[0])            # 2.5                   第 1 次是多少?
print(caps[-1])           # 2.33                  最后一次是多少?
print(caps[:3])           # [2.5, 2.46, 2.42]     前 3 次是多少?
print(max(caps), sum(caps) / len(caps))            # 最大 / 平均

列表是本书后面所有数据结构的起点:第 17 章的 NumPy 数组是"能整列运算的列表", 第 23 章的 DataFrame 是"带列名的列表集合"。 列表里现在要用 for 完成的事,将来都是一行代码 —— 但前提是先理解列表的规则。

6.2 创建与查看 ​

python
caps = [2.50, 2.46, 2.42, 2.38, 2.33]      # 方括号 + 逗号
print(caps, len(caps))                     # [2.5, 2.46, 2.42, 2.38, 2.33] 5

empty = []                                 # 空列表,等着往里放东西
zeros = [0] * 5                            # [0, 0, 0, 0, 0]  ← 和字符串的 * 同一个意思
写法得到说明
[1, 2, 3]三个元素的列表元素用逗号分隔
[]空列表len([]) 是 0,布尔值是 False(第 08 章)
[0] * 5[0, 0, 0, 0, 0]* 是重复;注意 [0] 的方括号不能省
list("C001")['C', '0', '0', '1']把一串字符拆成列表

列表的打印会自己"美化":2.50 打印成 2.5,字符串打印时带引号。 这是"你在看数据结构"而不是"在看文本"的信号。

6.3 索引:0 基是最容易错的地方 ​

python
caps = [2.50, 2.46, 2.42, 2.38, 2.33]
print(caps[0])     # 2.5    第 1 次
print(caps[2])     # 2.42   第 3 次 ← 不是 3!
print(caps[-1])    # 2.33   最后一次
print(caps[-2])    # 2.38   倒数第二次

把列表在脑子里摊开,每个元素下面写编号:

text
下标:   0      1      2      3      4
值:    2.50   2.46   2.42   2.38   2.33
负下标: -5     -4     -3     -2     -1
人的说法下标写法
第 1 次0caps[0]
第 3 次2caps[2]
第 n 次n-1caps[n - 1]
最后一次-1caps[-1](不用数长度)

转换口诀:caps[i] 是"第 i+1 次"。 看到 caps[3] 就念"第 4 次"。

为什么偏偏从 0 开始:下标在底层表示"偏移量" —— 首元素的地址偏移是 0。 这个设计从 C 语言一路传下来,Python、NumPy、Pandas、PyTorch 全都一样。 负下标的好处是 caps[-1] 永远指"最后一个",这支电芯跑 5 圈还是 800 圈,代码都不用改。

6.4 切片:左闭右开 ​

python
print(caps[:3])     # [2.5, 2.46, 2.42]          前 3 次
print(caps[1:4])    # [2.46, 2.42, 2.38]         下标 1、2、3
print(caps[-2:])    # [2.38, 2.33]               最后 2 次
print(caps[::2])    # [2.5, 2.42, 2.33]          每隔一个
print(caps[:])      # 整个列表(浅拷贝)

切片语法是 caps[起点:终点:步长],三个位置都可以省略:

写法含义结果
caps[:3]从头到下标 3(不含)下标 0,1,2
caps[1:4]下标 1 到 4(不含)下标 1,2,3
caps[3:]从下标 3 到结尾下标 3,4
caps[-2:]最后 2 个与长度无关
caps[::2]每 2 个取 1 个0,2,4
caps[::-1]倒序反向列表

"左闭右开"记法:终点写的是"我要停在哪里",不是"我要取几个"。 所以 caps[1:4] 取 3 个元素,长度等于 4 - 1。

点开:为什么是左闭右开?(理解了就不用死记)

① 长度直接相减:caps[1:4] 的长度 = 4 - 1 = 3。若右端也算,长度就是 4 - 1 + 1,每次都要 +1。

② 不重叠地切开:caps[:3] + caps[3:] 恰好拼回整个列表,没有元素被算两次。 这在"分块处理"(第 28 章的分组)里是关键性质。

③ 和 range 完全一致:range(1, 4) 是 1, 2, 3(第 07 章)。 切片和 range 用同一套规则,学一次管两处 —— 这不是巧合,是刻意设计。

怎么验证自己写对了:print(len(caps[1:4])),应该是 3。拿不准就打印长度,比在脑子里数靠谱。

6.5 增删改:append / pop / insert / del ​

python
caps = [2.50, 2.46, 2.42, 2.38, 2.33]

caps.append(2.29)     # 末尾追加 → [2.5, 2.46, 2.42, 2.38, 2.33, 2.29]
caps[0] = 2.51        # 直接改第 1 个 → [2.51, 2.46, 2.42, 2.38, 2.33, 2.29]
removed = caps.pop()  # 弹出末尾,返回被弹出的值 → 2.29
caps.insert(0, 2.50)  # 插到最前面 → [2.5, 2.51, 2.46, 2.42, 2.38, 2.33]
del caps[0]           # 按位置删 → [2.51, 2.46, 2.42, 2.38, 2.33]
操作作用返回值
caps.append(x)末尾追加None
caps.pop() / caps.pop(0)弹出末尾 / 第 1 个被弹出的元素
caps.insert(i, x)插到下标 i 前None
caps[i] = x改第 i 个——
del caps[i]删第 i 个——
caps.remove(x)删掉第一个等于 x 的元素None

列表是"可变对象":这些都是原地修改,改的就是那个列表对象本身。

append 返回 None —— 本章第一个静默 bug ​

python
demo = [1, 2]
print(demo.append(3))     # None  ← 打印出来的是返回值
print(demo)               # [1, 2, 3]  ← 列表自己变了

所以千万别写:

python
caps = caps.append(2.29)  # ❌ 列表确实变了,但 caps 被重新绑到 None 上!
print(caps)               # None
print(len(caps))          # TypeError: object of type 'NoneType' has no len()

为什么这么设计:append 是"命令"(去做一件事),不是"计算"(算出个结果)。 返回 None 就是在说"我没有结果给你"。

返回值行为例子
None原地修改append、insert、remove、sort
新对象 / 值原对象不变sorted、copy、pop(返回被删的元素)

6.6 排序与统计:内置函数,不用写循环 ​

python
print(sorted(caps))                  # 升序,返回新列表
print(sorted(caps, reverse=True))    # 降序
print(max(caps), min(caps), sum(caps))
print(caps.count(2.42))              # 出现几次
print(caps.index(2.42))              # 第一次出现的下标
print(round(sum(caps) / len(caps), 4))   # 均值 2.3967
函数作用注意
sorted(x)排序返回新列表,原列表不动
x.sort()原地排序返回 None,原列表被改
max / min / sum最大 / 最小 / 求和空列表 max 会报 ValueError
x.count(v)v 出现几次找不到返回 0
x.index(v)v 第一次出现的下标找不到报 ValueError(不是 -1!)

sorted(caps) 和 caps.sort() 的区别,是 5.6 节那条规律的又一次出现:

python
a = [3, 1, 2]
b = sorted(a)     # a 不变,b = [1, 2, 3]
a.sort()          # a 变成 [1, 2, 3],返回值是 None

平均值没有内置函数,最直接的写法是 sum(caps) / len(caps)。 这就是**"累加器模式"的最短形态**,第 07 章会把它展开成 for 循环 —— 看清"一行代码背后实际在做什么",是用好 NumPy 的前提。

6.7 混合类型的列表(为 NumPy 埋伏笔) ​

python
mixed = ["C001", 2.5, True, None]
for item in mixed:
    print(f"  {str(item):<8} → {type(item).__name__}")

输出:

text
  C001     → str
  2.5      → float
  True     → bool
  None     → NoneType

Python 的列表允许混装,但混装会让后续计算频繁报类型错误:

python
mixed = [1, 2, "3"]
print(sum(mixed))     # TypeError: unsupported operand type(s) for +: 'int' and 'str'

NumPy 的答案是"一个数组只能有一种类型"(第 17 章):np.array([1, 2.5, True]) 会把 int 和 bool 都升格成 float64。这带来了速度,也带来了"字符串一混进来,整列就废了"的风险。 工程纪律:一个列表只放一种东西。 编号放一个列表、容量放一个列表。

6.8 可变对象的陷阱:b = a 不是复制 ​

这是全章最重要的一节,也是第 03 章"变量是标签"的实测现场。

python
a = [1, 2, 3]
b = a                 # 不是复制!是两个名字贴同一个列表
b.append(4)
print(a, b)           # [1, 2, 3, 4] [1, 2, 3, 4]  ← a 也变了!
text
a = [1, 2, 3]      →      a ──┐
                              ├──> [1, 2, 3]      (一个对象,两个标签)
b = a              →      b ──┘
b.append(4)        →                  [1, 2, 3, 4]   ← 改的是那个对象

要真复制,明确说出来:

python
c = a.copy()          # ✅ 真复制(浅拷贝)
c.append(5)
print(a, c)           # [1, 2, 3, 4] [1, 2, 3, 4, 5]  ← a 不受影响

print(a[:] is a)      # False ← 切片 a[:] 也是浅拷贝,是新对象
print(a[:] == a)      # True  ← 内容相同,但不是同一个对象
点开:is 和 == 在列表上到底差在哪
写法问的问题列表上的结果
a == b内容一样吗?元素逐个比较,内容相同就是 True
a is b是同一个对象吗?只有指向同一块内存才 True
a is None是不是那个唯一的 None判空专用(PEP 8)
python
a = [1, 2, 3]
b = a
c = [1, 2, 3]
print(a == b, a == c)     # True True   ← 内容都一样
print(a is b, a is c)     # True False  ← 只有 b 是同一个对象

为什么要区分:a == c 为 True 时,改 a 不会影响 c; 而 a is b 为 True 时,改 a 一定会影响 b。 排查"我没改它啊,怎么变了"这类 bug,第一个动作就是看两个名字是不是同一个对象。

浅拷贝的"浅"也是一层坑(第 19、30 章细讲):a.copy() 只复制最外层:

python
x = [[1, 2], [3, 4]]
y = x.copy()
y[0].append(99)
print(x)      # [[1, 2, 99], [3, 4]]  ← 里层还是共享的!

要真深复制:import copy; copy.deepcopy(x)。本书的电池数据都是一维数值,copy() 就够。

6.9 越界:IndexError ​

python
caps = [2.50, 2.46, 2.42, 2.38, 2.33]
print(caps[99])
# IndexError: list index out of range

合法下标:0 ~ len(caps) - 1(正向),或 -1 ~ -len(caps)(反向)。 5 个元素,合法下标就是 0~4 或 -1~-5,caps[5] 就已经越界。 排查顺序:print(len(caps)) → 确认最大合法下标 → 回头看你写的那个下标。

场景错误写法正确写法
循环里用 1 开始编号caps[i](i 从 1 开始)caps[i-1] 或 enumerate(caps, start=1)
想取"第 n 次"caps[n]caps[n-1]
空列表取第 1 个caps[0]先判 if caps:(第 08 章)

切片不会越界,这一点和索引不同:

python
print(caps[2:99])     # [2.42, 2.38, 2.33]  不报错,自动截到结尾
print(caps[99:])      # []                  也不报错,空列表

为什么:切片的语义是"取一段范围",超出边界就"到此为止"; 索引问的是"第 99 个元素是谁",答不上来只能报错。这个差异在 NumPy 里同样成立(第 19 章)。

6.10 完整示例与输出 ​

bash
cd code
python demos/ch06-list.py

真实输出(节选):

text
=== ① 创建与查看 ===
caps = [2.5, 2.46, 2.42, 2.38, 2.33]   长度: 5

=== ② 索引(0 基,最容易错) ===
第 1 次循环:2.5 Ah      ← 下标 0
第 3 次循环:2.42 Ah      ← 下标 2,不是 3!
最后一次   :2.33 Ah     ← 负索引从右往左数

=== ③ 切片(左闭右开) ===
前 3 次   caps[:3]  = [2.5, 2.46, 2.42]
第 2~4 次 caps[1:4] = [2.46, 2.42, 2.38]   ← 下标 1,2,3
最后 2 次 caps[-2:] = [2.38, 2.33]
每隔一个  caps[::2] = [2.5, 2.42, 2.33]
整个列表  caps[:]   = [2.5, 2.46, 2.42, 2.38, 2.33]  (这是浅拷贝)

=== ④ 增删改 ===
append 之后: [2.5, 2.46, 2.42, 2.38, 2.33, 2.29] 长度: 6
pop() 返回 2.29,剩: [2.51, 2.46, 2.42, 2.38, 2.33]
del caps[0] 之后: [2.51, 2.46, 2.42, 2.38, 2.33]

=== ⑤ cap.append() 的返回值是 None ===
append 的返回值: None   ← None!千万别 caps = caps.append(x)
但 demo 自己变了: [1, 2, 3]

=== ⑥ 排序与查找 ===
升序 sorted(caps): [2.33, 2.38, 2.42, 2.46, 2.51]
最大值: 2.51  最小值: 2.33  求和: 12.1   均值: 2.42
2.42 出现了几次: 1     2.42 第一次出现在下标: 2

=== ⑦ 混合类型的列表 ===
mixed = ['C001', 2.5, True, None]
  C001     → str
  2.5      → float
  True     → bool
  None     → NoneType

=== ⑧ 可变对象的陷阱 ===
b.append(4) 之后:a = [1, 2, 3, 4]  b = [1, 2, 3, 4]  ← a 也变了!
c = a.copy() 后 c.append(5):a = [1, 2, 3, 4]  c = [1, 2, 3, 4, 5]
切片 a[:] 也是浅拷贝: False ← False 表示是两个对象

=== ⑨ 越界 ===
  IndexError: list index out of range
  合法下标:0 ~ 4 或 -1 ~ 5

注意最后一行 0 ~ 4 或 -1 ~ 5:这是 demo 的一处笔误 —— 反向合法下标应该是 -1 ~ -5。 读示例代码也要带着怀疑,这种"输出自己不自洽"的地方一眼就该看出来。

6.11 常见坑 ​

坑 ①:b = a 当成复制。 b = a 只是多贴一个标签,要复制写 b = a.copy()。 这是本书反复出现的第一大坑:第 17~19 章 NumPy 的"视图 vs 拷贝"、 第 27 章 Pandas 的 SettingWithCopyWarning,本质都是"你手上这个变量是不是独立的一份?"

坑 ②:caps = caps.append(x)。 返回 None,caps 直接变成 None。

坑 ③:把 caps[3] 说成"第 3 次"。 caps[3] 是第 4 次,口诀 caps[i] = 第 i+1 次。

坑 ④:切片终点写成"元素个数"。

python
caps[1:3]     # 取 2 个(下标 1、2)
caps[1:4]     # 取 3 个(下标 1、2、3)← 想取"第 2~4 次"要写这个

拿不准就 print(len(切片))。

坑 ⑤:sort() 的返回值当成排序结果。 b = caps.sort() 得到 None,要用 b = sorted(caps)。

坑 ⑥:index() 找不到会报错,不是返回 -1。caps.index(9.99) 抛 ValueError;要判断"有没有"就用 if 9.99 in caps:。 字符串的 find() 返回 -1(第 05 章),列表的 index() 抛异常 —— 这两个别搞混。

坑 ⑦:remove(x) 删的是"第一个等于 x 的元素",不是下标。caps.remove(2.42) 删的是值为 2.42 的元素;del caps[2] 才是按下标删。

坑 ⑧:以为"切片不越界"= "随便写都安全"。 caps[2:99] 没问题,caps[99] 直接崩。

本章练习 ​

练习 1:按循环次数取容量值

已知六次循环的容量:

python
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29]

依次打印:第 1 次的容量、第 3 次的容量(先想清楚下标是几)、最后一次的容量(用负下标,不许数), 再打印列表长度和合法下标范围,并验证 caps[-1] == caps[len(caps)-1]。

期望输出:

text
第 1 次循环:2.5 Ah
第 3 次循环:2.42 Ah
最后一次循环:2.29 Ah
(校验)列表长度 6,合法下标 0~5
         caps[-1] == caps[5] ? True
参考答案
python
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29]

# ① 第 1 次 → 下标 0
print(f"第 1 次循环:{caps[0]} Ah")

# ② 第 3 次 → 下标 2(★ 本练习唯一的坑)
print(f"第 3 次循环:{caps[2]} Ah")

# ③ 最后一次 → 负下标 -1(和长度无关,最好用)
print(f"最后一次循环:{caps[-1]} Ah")

# ④ 用长度验证
n = len(caps)
print(f"(校验)列表长度 {n},合法下标 0~{n - 1}")
print(f"         caps[-1] == caps[{n - 1}] ? {caps[-1] == caps[n - 1]}")

要点解释:

  1. caps[2] 是第 3 次。写成 caps[3] 会得到 2.38,而且不报错 —— 程序照跑、结果错了却看不出来,这就是这个坑危险的地方。
  2. caps[-1] 等价于 caps[len(caps)-1]。容量列表从 6 个变成 800 个,代码一个字都不用改。
  3. IndexError: list index out of range 是越界的报错。6 个元素的最大合法下标是 5。
  4. 打印列表是调试的第一手段:先 print(caps) 看真实内容,再谈下标。

完整练习在 exercises/ex04-06-list。

练习 2:用切片切出三段容量

还是 caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29],用切片(不许写循环、不许手打列表)打印:

  1. 前 3 次的容量;
  2. 第 2~4 次的容量,并打印长度,验证长度等于 4 - 1;
  3. 最后 2 次的容量;
  4. 每隔一个取一个。

期望输出:

text
前 3 次: [2.5, 2.46, 2.42]
第 2~4 次: [2.46, 2.42, 2.38]  (长度 3 = 4 - 1)
最后 2 次: [2.33, 2.29]
每隔一个: [2.5, 2.42, 2.33]
参考答案
python
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29]

# ① 前 3 次:起点省略默认从 0 开始
print(f"前 3 次: {caps[:3]}")

# ② 第 2~4 次:起点下标 1,终点写 4(右端取不到)→ 取下标 1,2,3
mid = caps[1:4]
print(f"第 2~4 次: {mid}  (长度 {len(mid)} = 4 - 1)")

# ③ 最后 2 次:负下标起点,省掉终点
print(f"最后 2 次: {caps[-2:]}")

# ④ 每隔一个:步长 2
print(f"每隔一个: {caps[::2]}")

要点解释:

  1. 终点写的是"停在哪里",不是"取几个"。想要"第 2~4 次"就写 caps[1:4],不是 caps[1:3]。 拿不准就 print(len(...))。
  2. 三个位置都能省:caps[:3](省起点)、caps[3:](省终点)、caps[::2](步长默认 1)。
  3. 切片返回新列表:改切片不影响原列表 —— 和 6.8 节的 b = a 正好相反, 因为切片创建了新对象。caps[:] 就是"整个列表的浅拷贝"。
  4. caps[1:99] 也不会报错,最多是取到结尾。
练习 3:预测输出(不要运行,先想)
python
a = [1, 2, 3]
b = a
b.append(4)
print(a, b)
print(a is b)

c = a.copy()
c.append(5)
print(a, c)
print(a[:] is a)
print(a[:] == a)

demo = [1, 2]
print(demo.append(3))
print(demo)

要求:先写下每一行输出,再运行验证。不一致的地方,说明原因。

参考答案

预测输出:

text
[1, 2, 3, 4] [1, 2, 3, 4]
True
[1, 2, 3, 4] [1, 2, 3, 4, 5]
False
True
None
[1, 2, 3]
行输出为什么
print(a, b)[1,2,3,4] [1,2,3,4]b = a 只是多贴一个标签,改的是同一个对象
print(a is b)True同一个对象
print(a, c)[1,2,3,4] / [1,2,3,4,5]a.copy() 建了新对象,c 的修改不影响 a
print(a[:] is a)False切片产生新对象(浅拷贝)
print(a[:] == a)True新对象但内容相同 —— == 比内容、is 比身份
print(demo.append(3))Noneappend 原地修改,没有返回值
print(demo)[1, 2, 3]但它确实改了列表本身

★ 把这张表记住,这是本章的总结:

text
b = a              两个名字 → 同一个对象   → 同生共死
c = a.copy()       新对象,内容相同        → 各过各的
a[:]               新对象(浅拷贝)        → 各过各的
demo.append(3)     原地改,返回 None       → 别接收返回值

最深的一层:print(a, c) 和 print(a[:] == a) 都在说"内容一样", 但一个会互相影响、一个不会。判断标准不是"值等不等",而是"是不是同一个对象"(is)。 第 19 章 NumPy 的"视图 vs 拷贝"就是这条规则的高危版本:那里一个切片可能还是视图, 改一下原数组,整批数据全变。

面向 AI 开发:列表就是"一批样本"的最小形态 ​

列表是 AI 项目里"数据集合"的第一个样子,理解它的边界,才知道为什么要升级到 NumPy 和 Pandas。

① 列表 = 一批样本,列表推导式 = 逐样本处理。

python
caps = [2.50, 2.46, 2.42]                          # 3 个样本的容量
sohs = [c / 2.5 for c in caps]                     # 每个样本做同一件事(第 11 章)
low = [c for c in caps if c / 2.5 < 0.85]          # 筛出低健康度的

神经网络训练时喂进去的就是"一批样本"(一个 batch)。 列表能装它们,但装不下一百万个 —— 这就是第 17 章 NumPy 存在的理由:

python
# 列表:整列运算必须写循环,慢
sohs = []
for c in caps:
    sohs.append(c / 2.5)

# NumPy:一行,底层是 C 循环,快几十倍(第 20 章)
sohs = np.array(caps) / 2.5

② 列表是"数据集"和"标签集"的天然配对,靠 zip 并排遍历(第 07 章)。

python
cell_ids = ["C001", "C002", "C003"]
sohs = [0.95, 0.88, 0.79]
for cid, soh in zip(cell_ids, sohs):
    print(f"{cid} SOH={soh:.2f}")

这正是"特征 + 标签"的雏形:X 一个列表、y 一个列表,下标一一对应。 下标对齐是数据集的命根子 —— 筛选、排序、切分训练集时,X 和 y 必须同进同出。 第 25~26 章会看到 Pandas 怎么用"索引"保证这件事,第 28 章用 groupby 一次性算完所有组。

③ 6.8 节的"别名陷阱"是 AI 代码里最难查的 bug。

python
X_norm = X_raw
X_norm[:] = (X_norm - mean) / std     # ❌ 把原始数据也改了,无法回退!

数据预处理管道里,"原始数据只读、处理产生新对象"是一条铁律。 所以工程代码里到处是 df.copy()、x.clone().detach() —— 它们都在解决 6.8 节的问题。

本章的坑在 AI / Pandas 里的对应
b = a 是别名df2 = df1 后改 df2,df1 也变了
a.copy() 浅拷贝df.copy(deep=True);NumPy 的 arr.copy()
a[:] 是浅拷贝(新对象)NumPy 的 arr[:] 是视图,不是拷贝!(第 19 章)
混装类型np.array([1, "2"]) 整列变字符串,再也算不了数(第 17 章)
append 返回 Nonedf.drop(...) 返回新表;inplace=True 才是原地改

一句话总结:列表是"批量样本"的第一个容器。它教会你两件必须带走的事 —— 下标从 0 开始、赋值不是复制。这两条在 NumPy、Pandas、PyTorch 里一个字都没变。


下一章:第 07 章 · 循环与切片:遍历每一圈