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 秒实验
- 把演示第四段的
steps >= 12改成steps >= 30,看序列往后长什么样, 再想想为什么没人能证明它一定到 1。 - 把
random.seed(20261102)删掉,连跑三次,看 73 还在不在。 - 把演示第六段的
sample + [400]改成sample + [-400],看均值往哪边跑,中位数变不变。
常见误区
- 「
range(10000000)会造一千万个数占满内存。」它只存三个数。 要真造出来才写list(range(...))。 这一条在循环很多次时能救命。 - 「
for k in d拿到的是键值对。」拿到的是键。要值写d.values(),两个都要写d.items()。 - 「
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; 中位数只看排在中间的那个,多一个再大的数也只是往后挤一格。 **数据里有离谱值时,中位数比均值更能代表「典型情况」。**下一篇把「一段逻辑包起来」这件事讲透:函数怎么写,参数和返回值怎么用。
- ☐ (本篇完,下一篇 3.3 自定义函数与再谈列表)