第 07 章 · 循环与切片:遍历每一圈
这一章要解决什么:不用手写 800 行代码,而是让程序替你把每一圈容量都过一遍、算一遍。 这一章要打掉三个坑:
range和切片一样左闭右开、for...else的else不是"否则"、别一边遍历一边改列表。
本章你需要完成的代码:demos/ch07-loop.py
7.1 for c in caps:最推荐的写法
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29]
cap_rated = 2.5
for c in caps:
print(f"容量 {c} Ah → SOH {c / cap_rated:.4f}")读法:for 每个元素 in 一串东西: —— 把 caps 里的值一个一个拿出来赋值给 c, 执行一遍缩进的代码块。
| 部分 | 作用 | 备注 |
|---|---|---|
for | 关键字 | 表示"接下来是一个循环" |
c | 循环变量 | 名字随便起,但要有意义(c/cap/soh) |
in caps | 要遍历的对象 | 列表、字符串、range、字典……(第 09 章) |
: | 冒号 | 不能少,且必须是半角 |
| 缩进 4 格 | 循环体 | 谁属于这个循环,由缩进决定(第 08 章) |
for 解决的是"对每个元素做同一件事":对每个样本预测、对每支电芯算 SOH、对每个文件读一遍。 推荐直接 for c in caps —— 你通常只关心"值",不需要知道"第几个"。
7.2 enumerate:同时拿到下标和值
for i, c in enumerate(caps, start=1):
print(f" 第 {i} 次循环,容量 {c} Ah,SOH {c / cap_rated:.4f}")输出:
第 1 次循环,容量 2.5 Ah,SOH 1.0000
第 2 次循环,容量 2.46 Ah,SOH 0.9840
...
第 6 次循环,容量 2.29 Ah,SOH 0.9160enumerate(可遍历对象, start=1) 每次吐出两个值:(序号, 元素)。 start=1 让序号从 1 开始,符合"第几次循环"的人话。
| 写法 | 可读性 | 出错风险 |
|---|---|---|
for c in caps | 高 | 拿不到下标 |
for i, c in enumerate(caps, start=1) | 高 | 低 —— 要下标就用它 |
for i in range(len(caps)) | 低 | caps[i] 和"第几次"差 1,最容易错 |
enumerate 返回的是元组,i, c = ... 是解包(第 11 章细讲)。
7.3 zip:两个列表并排走
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}")输出:
C001 SOH=0.95
C002 SOH=0.88
C003 SOH=0.79zip 把两个(或多个)列表"拉链式"并到一起:第 1 个配第 1 个、第 2 个配第 2 个……
| 要点 | 说明 |
|---|---|
| 长度不一致时 | 以短的为准,多出来的被丢掉(zip([1,2,3], "ab") → [(1,'a'), (2,'b')]) |
| 想带序号 | for i, (a, b) in enumerate(zip(x, y), start=1) |
| 转成列表 | list(zip(x, y)) → [(...), (...)] |
zip 最重要的用途是"让两个列表的下标严格对齐": cell_ids[i] 和 sohs[i] 必须说的是同一支电芯。这就是"特征 + 标签"的雏形(第 06 章)—— 一旦两个列表错位,后面所有结论都是错的。
7.4 range 的三种形态
list(range(5)) # [0, 1, 2, 3, 4]
list(range(1, 6)) # [1, 2, 3, 4, 5]
list(range(0, 10, 2)) # [0, 2, 4, 6, 8]
list(range(5, 0, -1)) # [5, 4, 3, 2, 1] ← 倒着数range(起点, 终点, 步长),和切片用的是同一套规则:左闭右开、起点默认 0、步长默认 1。
| 形态 | 例子 | 得到 |
|---|---|---|
| 只给终点 | range(5) | 0, 1, 2, 3, 4(不含 5) |
| 起点 + 终点 | range(1, 6) | 1, 2, 3, 4, 5 |
| 起点 + 终点 + 步长 | range(0, 10, 2) | 0, 2, 4, 6, 8 |
| 负步长 | range(5, 0, -1) | 5, 4, 3, 2, 1 |
range 不是列表,它是"按需生成数字"的对象(打印出来是 range(0, 5)), 要看到内容就 list(range(5))。这也是它省内存的原因:range(100000000) 不占 1 亿个数的内存。
for _ in range(3): # _ 表示"我不关心这个值"
print("训练一个 epoch")注意 range(5) 是 0~4 —— "跑 5 次"就写 range(5),别写 range(1, 5)(只有 4 次)。
7.5 break / continue / for...else
for i, c in enumerate(caps, start=1):
if c / cap_rated < 0.85:
print(f"第 {i} 次已低于 0.85 → break")
break
else:
print("循环跑完了都没找到")| 关键字 | 作用 | 类比 |
|---|---|---|
break | 立刻退出整个循环 | 提前下班 |
continue | 跳过本次,进入下一次 | 这次不算,下一个 |
for...else | 循环正常跑完才执行 else | "没找到的话……" |
真实输出(demo 的这段数据里没有低于 0.85 的,所以走了 else):
找第一个低于 0.85 的循环(break 提前退出):
循环跑完了都没找到点开:for...else 的 else 不是"否则",这是最容易误解的语法
别的语言里 else 都跟 if 配对,Python 的 for...else 却跟"有没有 break"配对:
for x in [1, 2, 3]:
if x == 99:
break
else:
print("没有 break 过") # ← 会打印!循环是"正常跑完"的规则一句话:循环跑完都没触发 break → 执行 else;一旦 break → else 被跳过。
它解决的场景是"找东西":
for i, c in enumerate(caps, start=1):
if c / cap_rated < 0.85:
print(f"首次跌破 0.85:第 {i} 次")
break
else:
print("这批数据里没有跌破 0.85 的循环")不用它也能写,但要引入一个标志变量:先 found = False,找到时 found = True 再 break, 循环后 if not found: 打印"没找到"。for...else 只是把这个标志变量省掉了。 如果你觉得它绕,用标志变量完全没问题 —— 可读性优先。但必须能看懂别人写的 for...else。
else 触发的条件是"没 break",不是"循环一次没跑" —— 循环体一次都没执行,else 也会执行。
continue 的用法:只处理关心的那部分。
for i, c in enumerate(caps, start=1):
if c / cap_rated < 0.90:
continue # 低于 0.90 的不打印,直接下一个
print(f"第 {i} 次:{c / cap_rated:.4f} 健康")break 和 continue 只影响"最内层"的那一个循环。嵌套循环时,break 不会跳出外层 —— 这是排查"为什么停了但外层还在跑"的关键。
7.6 累加器模式:for 循环的经典骨架
total = 0.0
n = 0
for c in caps:
total += c # 累加
n += 1 # 计数
print(f"循环版:总和 {total:.3f},均值 {total / n:.4f}")
print(f"内置版:总和 {sum(caps):.3f},均值 {sum(caps) / len(caps):.4f}")输出:
循环版:总和 14.380,均值 2.3967
内置版:总和 14.380,均值 2.3967累加器模式的三步(后面处理数据时到处都是):
① 循环前初始化:total = 0
② 循环里更新: total += 每一项
③ 循环后使用: total / ntotal += c 是 total = total + c 的简写,同样还有 -=、*=、/=。
| 想要求 | 累加器写法 | 内置写法 |
|---|---|---|
| 总和 | total += c | sum(caps) |
| 个数 | n += 1 | len(caps) |
| 最大 | if c > best: best = c | max(caps) |
| 筛选后求和 | 循环 + if | sum(c for c in caps if ...) |
既然有内置函数,为什么还要学循环版:① 复杂逻辑内置函数覆盖不了("只累加前 5 圈"、 "遇到异常值就跳过");② 它解释了内置函数在做什么 —— 第 20 章的"向量化"就是把这段循环压成一行; ③ 累加器是"在线算法"的雏形(数据一条条来、结果随时更新,比如训练时的 running loss)。
注意 n 和 len(caps) 的区别:n 是"我实际处理了几个"。 循环里写了 continue 时,n 会小于 len(caps) —— 这正是要学会手写累加器的原因。
7.7 while:不知道要跑几次时用
cycle = 1
soh = 1.0
while soh >= 0.80:
soh = 1.0 - 0.20 * (cycle / 1000) ** 1.2 # 书里的退化公式
cycle += 1
print(f"模拟到 SOH < 0.80 用了 {cycle - 1} 次循环(SOH={soh:.4f})")输出:
模拟到 SOH < 0.80 用了 1001 次循环(SOH=0.7998)for | while | |
|---|---|---|
| 什么时候用 | 知道要遍历什么 / 跑几次 | 不知道要跑几次,只知道停止条件 |
| 停止方式 | 遍历完自动停 | 条件变 False 才停 |
| 风险 | 低 | 死循环 |
cycle = 1
while cycle > 0: # ❌ 条件永远成立,而且循环里忘了改 cycle
print(cycle) # 疯狂打印,只能 Ctrl+C写 while 的纪律:进循环前问自己一句 —— "哪一行会让条件最终变成 False?" 答不上来就别写。上面正确版本里,答案是 cycle += 1。
7.8 别在遍历时改列表
nums = [1, 2, 3, 4]
bad = nums.copy()
for x in bad:
if x % 2 == 0:
bad.remove(x) # ❌ 边遍历边删
print(bad) # [1, 3] —— 看起来对?真实输出:
for + remove → [1, 3] ← 结果不对(4 没被删掉)点开:为什么 [1, 3] 这个"看起来对"的结果其实是错的
先说结论:demo 输出里那句注释("4 没被删掉")说反了 —— 它真正漏掉的是 3。 但"边遍历边删"这个坑是真实存在的,只是这段数据碰巧掩盖了它。
逐步看 [1, 2, 3, 4]:
| 轮次 | 循环拿到的 x | 列表 | 发生了什么 |
|---|---|---|---|
| 1 | 下标 0 → 1 | [1,2,3,4] | 奇数,不动 |
| 2 | 下标 1 → 2 | [1,2,3,4] | 偶数 → remove(2) → [1,3,4] |
| 3 | 下标 2 → 4 | [1,3,4] | 3 被跳过了! 4 → remove(4) → [1,3] |
| 4 | 下标 3 → 没了 | — | 循环结束 |
关键在第三轮:删掉 2 之后 3 补到了下标 1,但循环的指针已经走到下标 2, 于是 3 被跳过、4 被删。得到 [1, 3] —— 碰巧就是正确答案, 因为 3 是奇数,"跳过它"和"检查了但不删"结果一样。
换成全偶数就露馅了:
nums = [2, 4, 6]
for x in nums:
if x % 2 == 0:
nums.remove(x)
print(nums) # [4] ← 应该删光,只剩一个!推理:x=2 删掉 → [4,6],指针到下标 1 拿到 6,删掉 → [4],指针到下标 2 → 结束。 4 从头到尾没被看到。
这类 bug 最坏的地方是"正确性依赖于数据":同一段代码在 [1,2,3,4] 上对、在 [2,4,6] 上错。 正确写法是生成新列表:
[x for x in nums if x % 2] # [1, 3] / [] —— 永远对或者倒着遍历(for i in range(len(nums)-1, -1, -1)),从后往前删不影响还没检查的下标。
7.9 完整示例与输出
cd code
python demos/ch07-loop.py真实输出(节选):
=== ② enumerate:同时拿到下标和值 ===
第 1 次循环,容量 2.5 Ah,SOH 1.0000
第 2 次循环,容量 2.46 Ah,SOH 0.9840
...(共 6 行,第 6 次 SOH 0.9160)
=== ③ zip:两个列表并排走 ===
C001 SOH=0.95
C002 SOH=0.88
C003 SOH=0.79
zip 长度以短的为准: [(1, 'a'), (2, 'b')]
=== ④ range(左闭右开) ===
list(range(5)) = [0, 1, 2, 3, 4]
list(range(1, 6)) = [1, 2, 3, 4, 5]
list(range(0, 10, 2))= [0, 2, 4, 6, 8]
list(range(5, 0, -1))= [5, 4, 3, 2, 1] ← 倒着数
=== ⑤ break 与 continue ===
找第一个低于 0.85 的循环(break 提前退出):
循环跑完了都没找到
只打印健康的那几次(continue 跳过):
第 1 次:1.0000 健康
...(6 次全部健康,最低 0.9160)
=== ⑥ 累加器模式 ===
循环版:总和 14.380,均值 2.3967
内置版:总和 14.380,均值 2.3967
=== ⑦ while ===
模拟到 SOH < 0.80 用了 1001 次循环(SOH=0.7998)
⚠ while 一定要有能终止的条件,否则死循环(Ctrl+C 中断)
=== ⑧ 别在遍历时改列表 ===
想删掉所有偶数,错误写法:
for + remove → [1, 3] ← 结果不对(4 没被删掉)
正确写法:生成新列表(列表推导式,第 11 章):
[x for x in nums if x % 2] → [1, 3]7.10 常见坑
坑 ①:range 和切片一样左闭右开。 range(1, 5) 是 1,2,3,4,不是 1~5。 要跑 5 次用 range(5),要 1~5 用 range(1, 6)。终点总是取不到。
坑 ②:enumerate 不写 start,序号从 0 开始。
for i, c in enumerate(caps):
print(f"第 {i} 次") # ❌ 第一次打印"第 0 次"
for i, c in enumerate(caps, start=1):
print(f"第 {i} 次") # ✅坑 ③:for...else 当成"否则"。else 是"循环没被 break 打断",不是"循环一次没跑"。想表达"否则"就用 if not found:。
坑 ④:边遍历边删/加元素。
for x in nums:
if x % 2 == 0:
nums.remove(x) # ❌ 会漏元素改成"生成新列表":[x for x in nums if x % 2]。
坑 ⑤:while 忘了让条件变化 → 死循环。 循环体里必须有"推进条件"的语句(i += 1),否则只能 Ctrl+C。
坑 ⑥:循环变量会"泄漏"到循环外。
for i in range(3):
pass
print(i) # 2 ← 循环结束后 i 还在Python 的 for 不创建独立作用域。所以别在循环外复用同名变量 —— 你可能拿到上一次循环留下的值,而不是报 NameError。
坑 ⑦:在循环里拼接字符串/列表,性能很差。
s = ""
for c in caps:
s += str(c) # ❌ 每次都造一个新字符串
s = "".join(str(c) for c in caps) # ✅ 一次性拼好100 个元素无所谓,100 万个元素时差距是几十倍。字符串用 join,列表用推导式。
本章练习
练习 1:遍历六次循环并切出三段
已知:
cap_rated = 2.5
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29]要求:
- 用
for+enumerate(caps, start=1)打印每一次:「第 N 次循环,容量 X Ah,SOH Y」(容量 2 位、SOH 4 位小数); - 用切片打印最后 3 次的容量;
- 用切片打印第 2~4 次的容量;
- 用
for循环算平均 SOH(不许用sum()),再用内置写法算一遍,验证两者一致。
期望输出:
第 1 次循环,容量 2.50 Ah,SOH 1.0000
第 2 次循环,容量 2.46 Ah,SOH 0.9840
第 3 次循环,容量 2.42 Ah,SOH 0.9680
第 4 次循环,容量 2.38 Ah,SOH 0.9520
第 5 次循环,容量 2.33 Ah,SOH 0.9320
第 6 次循环,容量 2.29 Ah,SOH 0.9160
最后 3 次: [2.38, 2.33, 2.29]
第 2~4 次: [2.46, 2.42, 2.38]
(校验)循环版平均 SOH = 0.9587
内置版平均 SOH = 0.9587
两种写法一致吗? True参考答案
cap_rated = 2.5
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29]
# ① enumerate:同时拿到序号和值,start=1 让序号是"人话"
for i, c in enumerate(caps, start=1):
print(f"第 {i} 次循环,容量 {c:.2f} Ah,SOH {c / cap_rated:.4f}")
# ② 最后 3 次:负下标切片
print(f"最后 3 次: {caps[-3:]}")
# ③ 第 2~4 次:起点下标 1,终点写 4(右端取不到)
print(f"第 2~4 次: {caps[1:4]}")
# ④ 累加器:初始化 → 循环里累加 → 循环后使用
total = 0.0
n = 0
for c in caps:
total += c / cap_rated
n += 1
loop_avg = total / n
builtin_avg = sum(c / cap_rated for c in caps) / len(caps)
print(f"\n(校验)循环版平均 SOH = {loop_avg:.4f}")
print(f" 内置版平均 SOH = {builtin_avg:.4f}")
print(f" 两种写法一致吗? {abs(loop_avg - builtin_avg) < 1e-12}")要点解释:
enumerate(caps, start=1)的start=1不能忘,否则第一次打印"第 0 次循环"。- 切片左闭右开:
caps[1:4]取下标 1、2、3 共 3 个。拿不准就print(len(caps[1:4]))。 - 累加器三步:
total = 0→total += ...→total / n。n自己数,不要用len(caps), 因为一旦循环里有continue,两者就不相等了。 - 浮点比较要用 epsilon(第 04 章):
abs(a - b) < 1e-12,不要写a == b。
完整练习在 exercises/ex04-07-loop。
练习 2:找到"首次跌破阈值"的循环(break 实战)
已知八次循环的容量:
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29, 2.10, 1.95]
cap_rated = 2.5要求:
- 找出第一次 SOH < 0.85 的是第几次循环,打印后
break;整批都没有就用for...else打印"跑完了都没找到"; - 再用
continue打印所有 SOH ≥ 0.93 的循环(格式:第 N 次:0.9520 健康)。
期望输出:
第 7 次循环 SOH 0.8400 首次低于 0.85 → break
第 1 次:1.0000 健康
第 2 次:0.9840 健康
第 3 次:0.9680 健康
第 4 次:0.9520 健康
第 5 次:0.9320 健康参考答案
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29, 2.10, 1.95]
cap_rated = 2.5
# ① break + for...else:"找第一个"的标准骨架
for i, c in enumerate(caps, start=1):
soh = c / cap_rated
if soh < 0.85:
print(f"第 {i} 次循环 SOH {soh:.4f} 首次低于 0.85 → break")
break
else:
print("跑完了都没找到")
# ② continue:跳过不关心的,只处理关心的
for i, c in enumerate(caps, start=1):
if c / cap_rated < 0.93:
continue
print(f"第 {i} 次:{c / cap_rated:.4f} 健康")要点解释:
break把"找第一个"的代价降到最低:找到就停,不用把上千圈跑完。 电池寿命动辄上千次循环,"首次跌破阈值"这种查询用break是标配。for...else的else只在"没有break"时执行。这里第 7 次触发了break,所以else没打印。continue不是break:它只跳过当前这一轮。上面第 ② 段若误写成break,就只会打印第 1 次。- 心里先估一下数值:
2.10 / 2.5 = 0.84,再看输出对不对。
练习 3:预测输出(不要运行,先想)
print(list(range(3)))
print(list(range(1, 4)))
print(list(range(0, 6, 2)))
print(list(range(3, 0, -1)))
for i in range(3):
pass
print(i)
for x in [1, 2]:
if x == 9:
break
else:
print("没 break,else 执行")
nums = [1, 2, 3, 4]
for x in nums:
if x % 2 == 0:
nums.remove(x)
print(nums)要求:先写下每一行的输出,再运行验证。不一致的地方,说出原因。
参考答案
预测输出:
[0, 1, 2]
[1, 2, 3]
[0, 2, 4]
[3, 2, 1]
2
没 break,else 执行
[1, 3]最后一行的 [1, 3] 是"碰巧对"的 —— 想删掉所有偶数(2 和 4),恰好也得到 [1, 3], 但过程是错的:3 被跳过了、2 和 4 被删掉了。换成 [2, 4, 6] 就会只剩 [4]。
初始: [1, 2, 3, 4]
第 1 轮 x=1 [1, 2, 3, 4] 不动
第 2 轮 x=2 [1, 3, 4] 删掉 2,3 和 4 往前挪
第 3 轮 x=4 [1, 3] 3 被跳过了!删掉 4
第 4 轮 循环结束(下标 3 已经没有元素)| 表达式 | 结果 | 为什么 |
|---|---|---|
range(3) | 0,1,2 | 左闭右开,不含 3 |
range(1,4) | 1,2,3 | 起点 1、终点 4(不含) |
range(0,6,2) | 0,2,4 | 步长 2 |
range(3,0,-1) | 3,2,1 | 负步长倒着数,终点 0 取不到 |
print(i) | 2 | 循环变量泄漏到循环外(for 没有独立作用域) |
for...else | 打印 else | 没触发 break,循环"正常结束" → 执行 else |
| 边遍历边删 | [1, 3] | 结果碰巧对,但 3 被跳过,语义是错的 |
两条结论:
for循环的变量在循环结束后仍然存在(i还是 2)。别在循环外用同名变量, 也别指望"循环没跑过就报 NameError"。- 永远不要一边遍历一边修改列表。正确写法是生成新列表:
[x for x in nums if x % 2]→[1, 3](这次是因为代码对,不是因为碰巧)。
面向 AI 开发:循环是"训练"和"批处理"的形状
AI 代码里到处是循环,但你现在写的这种"逐个元素循环"恰恰是要尽量避免的。 第 06 章已经讲过"列表 vs 向量化",这里只看循环在真实训练代码里的对应物。
① 训练循环(training loop)是最典型的 for。
for epoch in range(n_epochs): # 外层:跑几轮
for batch in loader: # 内层:一批批喂数据
loss = model(batch)
loss.backward()
optimizer.step()| 本章的知识点 | 在训练代码里的对应 |
|---|---|
for ... in ... | for epoch in range(n_epochs),range(n) 就是"跑 n 次" |
break | 早停(early stopping):验证集不再改善就跳出训练 |
continue | 跳过坏 batch / 无效样本(if not ok: continue) |
| 累加器 | running_loss += loss.item(),每个 epoch 打印平均损失 |
enumerate / zip | 打印第几个 batch;for x, y in zip(features, labels) 取"特征 + 标签" |
② while 对应"不确定多少步"的过程。
while soh >= 0.80: # 一直循环到退役
soh = degrade(soh)强化学习的 rollout、迭代求解、while not converged: 都是这个形状。 记得给它加保险:while ... and step < MAX_STEPS:,否则一次 bug 就能跑一整夜。
③ "别在遍历时改列表"是数据清洗的入门级纪律。
# ❌ 一边遍历一边删
for i, row in enumerate(rows):
if is_bad(row):
rows.pop(i)
# ✅ 生成新数据,原数据只读
rows = [r for r in rows if not is_bad(r)]Pandas 里对应的就是第 34 章"异常处置"的原则:默认"标记 + 单独分析",而不是直接删 —— 因为删数据不可逆,而"生成一份新的干净数据"随时可以回退。
一句话总结:循环是"对每个元素做同一件事"的形状。 你先用 for 把逻辑写清楚,再学 NumPy 把它压成一行 —— 顺序不能反:不知道循环在做什么的人,用向量化只会更快地算错。