Skip to content

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

这一章要解决什么:不用手写 800 行代码,而是让程序替你把每一圈容量都过一遍、算一遍。 这一章要打掉三个坑:range 和切片一样左闭右开、for...else 的 else 不是"否则"、别一边遍历一边改列表。

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

7.1 for c in caps:最推荐的写法 ​

python
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:同时拿到下标和值 ​

python
for i, c in enumerate(caps, start=1):
    print(f"  第 {i} 次循环,容量 {c} Ah,SOH {c / cap_rated:.4f}")

输出:

text
  第 1 次循环,容量 2.5 Ah,SOH 1.0000
  第 2 次循环,容量 2.46 Ah,SOH 0.9840
  ...
  第 6 次循环,容量 2.29 Ah,SOH 0.9160

enumerate(可遍历对象, 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:两个列表并排走 ​

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}")

输出:

text
  C001  SOH=0.95
  C002  SOH=0.88
  C003  SOH=0.79

zip 把两个(或多个)列表"拉链式"并到一起:第 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 的三种形态 ​

python
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 亿个数的内存。

python
for _ in range(3):          # _ 表示"我不关心这个值"
    print("训练一个 epoch")

注意 range(5) 是 0~4 —— "跑 5 次"就写 range(5),别写 range(1, 5)(只有 4 次)。

7.5 break / continue / for...else ​

python
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):

text
  找第一个低于 0.85 的循环(break 提前退出):
    循环跑完了都没找到
点开:for...else 的 else 不是"否则",这是最容易误解的语法

别的语言里 else 都跟 if 配对,Python 的 for...else 却跟"有没有 break"配对:

python
for x in [1, 2, 3]:
    if x == 99:
        break
else:
    print("没有 break 过")        # ← 会打印!循环是"正常跑完"的

规则一句话:循环跑完都没触发 break → 执行 else;一旦 break → else 被跳过。

它解决的场景是"找东西":

python
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 的用法:只处理关心的那部分。

python
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 循环的经典骨架 ​

python
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}")

输出:

text
  循环版:总和 14.380,均值 2.3967
  内置版:总和 14.380,均值 2.3967

累加器模式的三步(后面处理数据时到处都是):

text
① 循环前初始化:total = 0
② 循环里更新:  total += 每一项
③ 循环后使用:  total / n

total += c 是 total = total + c 的简写,同样还有 -=、*=、/=。

想要求累加器写法内置写法
总和total += csum(caps)
个数n += 1len(caps)
最大if c > best: best = cmax(caps)
筛选后求和循环 + ifsum(c for c in caps if ...)

既然有内置函数,为什么还要学循环版:① 复杂逻辑内置函数覆盖不了("只累加前 5 圈"、 "遇到异常值就跳过");② 它解释了内置函数在做什么 —— 第 20 章的"向量化"就是把这段循环压成一行; ③ 累加器是"在线算法"的雏形(数据一条条来、结果随时更新,比如训练时的 running loss)。

注意 n 和 len(caps) 的区别:n 是"我实际处理了几个"。 循环里写了 continue 时,n 会小于 len(caps) —— 这正是要学会手写累加器的原因。

7.7 while:不知道要跑几次时用 ​

python
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})")

输出:

text
  模拟到 SOH < 0.80 用了 1001 次循环(SOH=0.7998)
forwhile
什么时候用知道要遍历什么 / 跑几次不知道要跑几次,只知道停止条件
停止方式遍历完自动停条件变 False 才停
风险低死循环
python
cycle = 1
while cycle > 0:        # ❌ 条件永远成立,而且循环里忘了改 cycle
    print(cycle)        # 疯狂打印,只能 Ctrl+C

写 while 的纪律:进循环前问自己一句 —— "哪一行会让条件最终变成 False?" 答不上来就别写。上面正确版本里,答案是 cycle += 1。

7.8 别在遍历时改列表 ​

python
nums = [1, 2, 3, 4]
bad = nums.copy()
for x in bad:
    if x % 2 == 0:
        bad.remove(x)      # ❌ 边遍历边删
print(bad)                 # [1, 3] —— 看起来对?

真实输出:

text
    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 是奇数,"跳过它"和"检查了但不删"结果一样。

换成全偶数就露馅了:

python
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] 上错。 正确写法是生成新列表:

python
[x for x in nums if x % 2]         # [1, 3] / [] —— 永远对

或者倒着遍历(for i in range(len(nums)-1, -1, -1)),从后往前删不影响还没检查的下标。

7.9 完整示例与输出 ​

bash
cd code
python demos/ch07-loop.py

真实输出(节选):

text
=== ② 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 开始。

python
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:。

坑 ④:边遍历边删/加元素。

python
for x in nums:
    if x % 2 == 0:
        nums.remove(x)     # ❌ 会漏元素

改成"生成新列表":[x for x in nums if x % 2]。

坑 ⑤:while 忘了让条件变化 → 死循环。 循环体里必须有"推进条件"的语句(i += 1),否则只能 Ctrl+C。

坑 ⑥:循环变量会"泄漏"到循环外。

python
for i in range(3):
    pass
print(i)      # 2  ← 循环结束后 i 还在

Python 的 for 不创建独立作用域。所以别在循环外复用同名变量 —— 你可能拿到上一次循环留下的值,而不是报 NameError。

坑 ⑦:在循环里拼接字符串/列表,性能很差。

python
s = ""
for c in caps:
    s += str(c)        # ❌ 每次都造一个新字符串
s = "".join(str(c) for c in caps)     # ✅ 一次性拼好

100 个元素无所谓,100 万个元素时差距是几十倍。字符串用 join,列表用推导式。

本章练习 ​

练习 1:遍历六次循环并切出三段

已知:

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

要求:

  1. 用 for + enumerate(caps, start=1) 打印每一次:「第 N 次循环,容量 X Ah,SOH Y」(容量 2 位、SOH 4 位小数);
  2. 用切片打印最后 3 次的容量;
  3. 用切片打印第 2~4 次的容量;
  4. 用 for 循环算平均 SOH(不许用 sum()),再用内置写法算一遍,验证两者一致。

期望输出:

text
第 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
参考答案
python
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}")

要点解释:

  1. enumerate(caps, start=1) 的 start=1 不能忘,否则第一次打印"第 0 次循环"。
  2. 切片左闭右开:caps[1:4] 取下标 1、2、3 共 3 个。拿不准就 print(len(caps[1:4]))。
  3. 累加器三步:total = 0 → total += ... → total / n。n 自己数,不要用 len(caps), 因为一旦循环里有 continue,两者就不相等了。
  4. 浮点比较要用 epsilon(第 04 章):abs(a - b) < 1e-12,不要写 a == b。

完整练习在 exercises/ex04-07-loop。

练习 2:找到"首次跌破阈值"的循环(break 实战)

已知八次循环的容量:

python
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29, 2.10, 1.95]
cap_rated = 2.5

要求:

  1. 找出第一次 SOH < 0.85 的是第几次循环,打印后 break;整批都没有就用 for...else 打印"跑完了都没找到";
  2. 再用 continue 打印所有 SOH ≥ 0.93 的循环(格式:第 N 次:0.9520 健康)。

期望输出:

text
第 7 次循环 SOH 0.8400 首次低于 0.85 → break
第 1 次:1.0000 健康
第 2 次:0.9840 健康
第 3 次:0.9680 健康
第 4 次:0.9520 健康
第 5 次:0.9320 健康
参考答案
python
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} 健康")

要点解释:

  1. break 把"找第一个"的代价降到最低:找到就停,不用把上千圈跑完。 电池寿命动辄上千次循环,"首次跌破阈值"这种查询用 break 是标配。
  2. for...else 的 else 只在"没有 break"时执行。这里第 7 次触发了 break,所以 else 没打印。
  3. continue 不是 break:它只跳过当前这一轮。上面第 ② 段若误写成 break,就只会打印第 1 次。
  4. 心里先估一下数值:2.10 / 2.5 = 0.84,再看输出对不对。
练习 3:预测输出(不要运行,先想)
python
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)

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

参考答案

预测输出:

text
[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]。

text
初始:            [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 被跳过,语义是错的

两条结论:

  1. for 循环的变量在循环结束后仍然存在(i 还是 2)。别在循环外用同名变量, 也别指望"循环没跑过就报 NameError"。
  2. 永远不要一边遍历一边修改列表。正确写法是生成新列表: [x for x in nums if x % 2] → [1, 3](这次是因为代码对,不是因为碰巧)。

面向 AI 开发:循环是"训练"和"批处理"的形状 ​

AI 代码里到处是循环,但你现在写的这种"逐个元素循环"恰恰是要尽量避免的。 第 06 章已经讲过"列表 vs 向量化",这里只看循环在真实训练代码里的对应物。

① 训练循环(training loop)是最典型的 for。

python
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 对应"不确定多少步"的过程。

python
while soh >= 0.80:                 # 一直循环到退役
    soh = degrade(soh)

强化学习的 rollout、迭代求解、while not converged: 都是这个形状。 记得给它加保险:while ... and step < MAX_STEPS:,否则一次 bug 就能跑一整夜。

③ "别在遍历时改列表"是数据清洗的入门级纪律。

python
# ❌ 一边遍历一边删
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 把它压成一行 —— 顺序不能反:不知道循环在做什么的人,用向量化只会更快地算错。


下一章:第 08 章 · 条件判断:是否达到退役线