🐍 第3章 列表、循环与自定义函数
📄 一句话概括

for 不关心后面是什么,它只管「下一个」——列表给元素、字符串给字符、range 给数字、字典给键。 range 不是列表,它是一条「下一个给什么」的规则,所以它不占内存。 程序库就是别人写好的函数。random.seed() 固定种子以后,「随机数」每次都一样。

📝 本章和前面几章的关系

上一篇列表已经用过 for,但只用了「走一遍」这一种姿势。 教材第 3 章中间两节是【使用 for in 读取序列结构数据】和【引用程序库】。 本篇把 for、range、enumerate、zip、while 一次讲清,再接上标准库。 末尾那个均值与中位数的对照,是教材里少有的「同一份数据两种算法给出两个结论」的例子, 值得单独说一段。

一、for 只管「下一个」

演示第一段把五种东西挨个扔给 for,全都走通了:列表给元素、字符串给字符、range 给数字、 字典的 keys() 给键、items() 给键值对。 for 不关心后面是个什么东西,它只问一句「还有下一个吗」。 这种「能一个一个拿出来」的性质, 叫可迭代。 所以判断一个东西能不能 for,不用背清单,问自己:它能不能一个个拿出来? 文件能(一行行拿)、网页响应能(一块块拿)、生成器能(第 5 章会讲)。

字典的 for k in d 默认给的是键,不是值,也不是键值对。 想要值写 d.values(),两个都要写 d.items()。这个默认值让很多人第一次用字典时愣住了。

二、range:一条规则,不是一个列表

写法 给出 个数
range(5) 0 1 2 3 4 5
range(2, 8) 2 3 4 5 6 7 6
range(0, 10, 3) 0 3 6 9 4
range(5, 0, -1) 5 4 3 2 1 5

三种写法:只给终点、给起点和终点、再给一个步长。步长为负就是倒着走。 关键的一点:range 本身不占内存。 它不存那串数字,只存「起点、终点、步长」三个数,你要第几个它现算。 所以 range(10000000) 占的内存和 range(10) 一样多。 想看内容得 list() 一下, 那一步才真的把一千万个数造出来。

这一条在「循环一千万次」的时候很关键:写 for i in range(10000000) 不费内存, 写 for i in list(range(10000000)) 直接吃掉几百兆。

三、要序号用 enumerate,要配对用 zip

想在走列表的同时拿到序号,很多人写 for i in range(len(x)),然后 x[i]。能跑,但不干净。 for i, v in enumerate(x, 1) 一次给序号和值,那个 1 表示从 1 开始数(不写就默认从 0)。 这一个参数省掉了满篇的 i + 1。 两个列表要一起走,用 zip:for name, qty in zip(names, qtys)。

zip 有个必须知道的脾气:短的走完就停,不报错。 两个列表长度不一样时, 多出来的那些会被悄悄丢掉。 所以用 zip 之前最好确认两边一样长。 不一样长又要全走完,得用 itertools.zip_longest,那是另一个话题了。

四、while 与它的两个出口

for 是「有多少个就走多少个」,while 是「条件还成立就继续」。 演示里用考拉兹序列做例子:奇数就乘三加一,偶数就除二,一直做到 1 为止。 这个循环跑多少轮事先不知道,只能写 while n != 1。 结果 27 这个数走了 111 步才到 1。规则只有两行,步数完全不可预测。

顺带说一句:这个序列至今没人能证明「所有正整数都会走到 1」。 算到很大的数都成立,但「算过很多」不等于「证明了对所有数成立」。 while 有两个出口:break 直接跳出整个循环,continue 跳过这一轮、接着下一轮。 演示里「跳过偶数」用的就是 continue。 写 while 最怕忘了让条件变化。 循环体里不推那个变量,就是死循环。真卡住了按 Ctrl+C 中断。

五、引用程序库:import 一句话

程序库就是别人写好的函数集合。import math 之后,math.sqrt、math.floor、math.pi 全能用了。 标准库有几百个模块,本书用到的大概十来个。 常用的几个:math 算数学、random 造随机、statistics 算统计、os 操作文件、 datetime 处理时间、json 读写 JSON。 random 有一个东西必须知道:它给的其实不是真随机,是一串算好的数。 起点叫种子,种子一样,后面整串就一样。

random.seed(20261102) 之后 randint(1, 100) 每次跑都给 73。 这不是 bug,这是特性。 做实验、写测试时靠它让结果可复现; 忘了它就每次结果都不一样,出了问题没法重现。 statistics 里的三个函数值得记:mean 均值、median 中位数、stdev 标准差。 前两个下一篇会说为什么要分开算。 import 的几种写法也要认:import math 之后写 math.sqrt; from math import sqrt 之后直接写 sqrt;import numpy as np 是起个别名。 本书统一用第一种,因为看得清来源。

六、均值和中位数,什么时候分家

演示最后做了一组对照:十次正常出库,均值 15.0、中位数 15.0,两个数一样。 然后混进一次 400 的误操作,变成 11 个数。均值跳到 50.0,中位数还是 15.0。 一个离谱值把均值拉高了 35,中位数一点没动。

原因不难想:均值要拿总和除以个数,那个 400 全部进了总和; 中位数只看「排好队之后站在中间的那个」,多一个再大的数也只是把它往旁边挤一格。 所以看库存、看响应时间、看工资这类「偶尔会有离谱值」的数据,中位数比均值诚实。 看总量、看平均每单金额这类「每笔都该差不多」的数据,均值更合适。 两个都算一遍,再看它们差多远,是最省事的判断办法。差得离谱就说明有异常值,该去查数据了。

七、动手 Demo:for 走序列,while 看条件

先把五种东西挨个扔给 for,看它到底能走什么。 再看 range 的四种写法,以及它为什么不占内存。 然后枚举、配对、while、break、continue 各来一遍。 最后引三个标准库,并用一份带异常值的数据看均值和中位数怎么分家。

print('① for in 能走哪些东西')
for tag, seq in (("列表  [1,2,3]", [1, 2, 3]), ("字符串 'abc'", 'abc'),
                  ("range(4)", range(4)), ("字典 .keys()", {'a': 1, 'b': 2}.keys()),
                  ("字典 .items()", {'a': 1, 'b': 2}.items())):
    print('  %-16s →' % tag, ' '.join(str(x).replace('(', '').replace(')', '')
                                        .replace("'", '').replace(', ', '=')
                                        for x in seq))

print('② range 的三种写法')
for src in ('range(5)', 'range(2, 8)', 'range(0, 10, 3)', 'range(5, 0, -1)'):
    print('  %-18s →' % src, list(eval(src)))
import sys
print('  range(10) 占 %d 字节,range(10**8) 占 %d 字节'
      % (sys.getsizeof(range(10)), sys.getsizeof(range(10 ** 8))))

print('③ 边遍历边要序号')
stock = ['螺丝', '扳手', '电钻']
print('  enumerate:')
for i, name in enumerate(stock, 1):
    print('    %d. %s' % (i, name))
print('  zip 两个列表:')
for name, qty in zip(stock, [3, 12, 1]):
    print('    %-6s %d' % (name, qty))

print('④ while 与 break、continue')
n, steps, path = 27, 0, []
while n != 1:
    path.append(n)
    n = n * 3 + 1 if n % 2 else n // 2
    steps += 1
    if steps >= 12:
        break
print('  27 的考拉兹序列前 12 项:', ' → '.join(str(x) for x in path))
m, total = 27, 0
while m != 1:
    m = m * 3 + 1 if m % 2 else m // 2
    total += 1
print('  一共走了 %d 步才到 1' % total)
odd = []
for i in range(10):
    if i % 2 == 0:
        continue
    odd.append(i)
print('  跳过偶数的写法:', ' '.join(str(x) for x in odd))

print('⑤ 引用程序库')
import math, random, statistics
print('  math.sqrt(144)   =', math.sqrt(144))
print('  math.floor(3.7)  =', math.floor(3.7))
print('  math.pi          = %.6f' % math.pi)
random.seed(20261102)
print('  random.seed(20261102) 后 randint(1,100) =', random.randint(1, 100))
sample = [12, 15, 9, 22, 15, 18, 15, 30, 11, 14]
print('  样本', sample)
print('  mean   = %.2f' % statistics.mean(sample))
print('  median = %.1f' % statistics.median(sample))
print('  stdev  = %.2f' % statistics.stdev(sample))

print('⑥ 均值和中位数什么时候分家')
print('| 数据 | 个数 | 均值 | 中位数 |')
print('|---|---:|---:|---:|')
for tag, data in (('十次正常出库', sample), ('多了一次 400 的误操作', sample + [400])):
    print('| %s | %d | %.1f | %.1f |'
          % (tag, len(data), statistics.mean(data), statistics.median(data)))
m1, m2 = statistics.mean(sample), statistics.mean(sample + [400])
print('  均值高了 %.1f,中位数没动。' % (m2 - m1))

实跑输出:

--------------------------------------------------------------------
for 走序列,while 看条件
--------------------------------------------------------------------

  ① for in 能走哪些东西
  --------------------------------------------------------------------
    列表  [1,2,3]     → 1 2 3
    字符串 'abc'      → a b c
    range(4)         → 0 1 2 3
    字典 .keys()     → a b
    字典 .items()    → a=1 b=2
  凡是能一个一个拿出来的东西都能 for:列表给元素、字符串给字符、range 给数字、字典给键。
  for 不关心后面是什么,只管「下一个」。

  ② range 的三种写法
  --------------------------------------------------------------------
    range(5)      → [0, 1, 2, 3, 4]
    range(2, 8)   → [2, 3, 4, 5, 6, 7]
    range(0, 10, 3) → [0, 3, 6, 9]
    range(5, 0, -1) → [5, 4, 3, 2, 1]
  range 本身不占内存,它是「下一个给什么」的规则,不是列表。
  要看内容得 list() 一下——上了几百万个数时这一条很关键。

  ③ 边遍历边要序号
  --------------------------------------------------------------------
    enumerate:
      1. 螺丝
      2. 扳手
      3. 电钻
    zip 两个列表:
      螺丝     3
      扳手     12
      电钻     1
  enumerate 同时给你序号和值,比 range(len(x)) 干净。zip 把两个等长列表拉在一起走,短的走完就停。

  ④ while 与 break、continue
  --------------------------------------------------------------------
    27 的考拉兹序列前 12 项:27 → 82 → 41 → 124 → 62 → 31 → 94 → 47 → 142 → 71 → 214 → 107
    一共走了 111 步才到 1
    跳过偶数的写法:1 3 5 7 9
  while 只看条件,条件什么时候变假靠你自己在循环体里推。
  break 是直接跳出,continue 是跳过这一轮、接着下一轮。
  上面这个序列叫考拉兹序列,规则极简单,
  但至今没人能证明它对所有正整数都会走到 1——计算不等于能证明。

  ⑤ 引用程序库
  --------------------------------------------------------------------
    import math
      math.sqrt(144)      = 12.0
      math.floor(3.7)     = 3
      math.pi             = 3.141593
    import random
      random.seed(20261102) 固定种子后:
      random.randint(1,100) = 73
    import statistics
      样本 [12, 15, 9, 22, 15, 18, 15, 30, 11, 14]
      mean   = 16.10
      median = 15.0
      stdev  = 6.08
  程序库就是别人写好的函数,import 进来直接用。
  random.seed() 固定种子之后,每次跑出来的「随机数」都一样——做实验、写测试时靠这个让结果可复现。

  ⑥ 均值和中位数什么时候分家
  --------------------------------------------------------------------

| 数据 | 个数 | 均值 | 中位数 |
|---|---:|---:|---:|
| 十次正常出库 | 10 | 15.0 | 15.0 |
| 多了一次 400 的误操作 | 11 | 50.0 | 15.0 |

  一个错数把均值从 15.0 拉到 50.0,高了 35.0;中位数一点没动。
  看库存、看响应时间这类会被个别离谱值带偏的数据,中位数比均值诚实。

第一段那五种东西形态差别很大,但 for 全接住了。因为它只问「还有下一个吗」,不问「你是什么」。 第二段最后那行才是重点:range(10) 和一亿那么大的 range 占的字节数一样。 因为 range 只存起点、终点、步长三个数,不存那串数字本身。 要真造出来得 list() 一下,那一步才会吃内存。 第三段两个工具解决两种需求:enumerate 要序号,zip 要配对。 注意 zip 是短的走完就停,两个列表不一样长时多的那些会被悄悄丢掉。 第四段那个 111 步靠一个 while 数出来的。规则只有两行,步数完全不可预测。 continue 跳过本轮、break 跳出整个循环,两个别混。 第五段 random.seed 固定种子之后,73 这个数每次跑都一样。这不是 bug,是让实验能复现的手段。 第六段是本篇的收尾:一个 400 把均值拉高 35,中位数一点没动。 看库存、看响应时间这类数据,中位数比均值诚实。

30 秒实验

  1. 把演示第四段的 steps >= 12 改成 steps >= 30,看序列往后长什么样, 再想想为什么没人能证明它一定到 1。
  2. 把 random.seed(20261102) 删掉,连跑三次,看 73 还在不在。
  3. 把演示第六段的 sample + [400] 改成 sample + [-400],看均值往哪边跑,中位数变不变。

常见误区

  1. 「range(10000000) 会造一千万个数占满内存。」它只存三个数。 要真造出来才写 list(range(...))。 这一条在循环很多次时能救命。
  2. 「for k in d 拿到的是键值对。」拿到的是键。要值写 d.values(),两个都要写 d.items()。
  3. 「zip 会自动补长。」它是短的走完就停,多出来的悄悄丢掉,不报错。 两个列表长度不一样时,你不会收到任何提示,只会少处理几笔。
自测三问(点开对答案) 1. `range(10)` 和 `list(range(10))` 占的内存差多少?为什么? —— 差很多。`range` 只存起点、终点、步长三个数,占几十字节; `list` 会把十个整数真造出来,还要加列表本身的开销。 所以循环里写 `for i in range(n)` 不费内存。 2. `random.seed(20261102)` 之后每次跑都得到同一个数,这算 bug 吗? —— 不算,这是设计。`random` 给的是一串算好的数,种子决定从哪儿开始。种子固定,整串就固定。 做实验、写测试时靠它让结果可复现,不写种子才是「每次都不一样」。 3. 十次出库均值 15、中位数 15,加一次 400 之后均值 50、中位数还是 15。这说明什么? —— 说明均值会把异常值全额吃进去,中位数不吃。400 全部进了总和,除以 11 之后把均值抬了 35; 中位数只看排在中间的那个,多一个再大的数也只是往后挤一格。 **数据里有离谱值时,中位数比均值更能代表「典型情况」。**

下一篇把「一段逻辑包起来」这件事讲透:函数怎么写,参数和返回值怎么用。

相关笔记