📖 工程视角:工程数据总是成批出现 工程现场几乎不存在「孤立一个」的数据:液压系统的压力传感器每秒上报一个读数,一天就是 86400 个数;一次结构静力试验的应变测点动辄上百上千;设备的故障日志逐条累积。工程软件的第一步往往就是把这些成批数据组织好——采样序列按时间排进列表,再批量统计、批量回放。可以说,数据结构是工程数据处理的「容器学」:先选对容器,后面的算法才谈得上效率。
🎯 本章学习目标
- 理解「数据结构」就是组织和管理一群数据的方式。
- 掌握 Python 列表 list 的创建、下标访问和常用操作。
- 理解数组在内存里的「连续」布局,以及下标从 0 开始的原因。
- 理解「随机访问」的本质:下标 → 地址的数学换算。
- 初步认识字典、元组等其他常用结构。
10.1从「一个变量」到「一群数据」
回顾第 4 章:一个变量就是「一块命名的内存」,存一个值。但现实中我们常常要处理一群数据。比如要存全班 50 个学生的成绩,用变量会怎么样?
score1 = 88
score2 = 76
score3 = 95
# …… 要写 50 行,写到手软
这不仅啰嗦,还带来一个致命问题:你没法用一个循环去遍历它们——因为 score1、score2 这些名字是散的,没有「顺序」,循环无从下手。
我们需要一种「把多个值装在一起、有顺序、能批量处理」的东西——这就是 数据结构(data structure)。
核心思想 数据结构 = 组织和管理一群数据的方式。单个变量解决「存一个」,数据结构解决「存一堆 + 有序 + 可批量操作」。程序处理的数据越多,数据结构就越重要——它决定了数据「怎么放」,进而决定「怎么找、怎么改」。
深挖一层:为什么「散的变量」注定无法批量处理
把这个痛点说透。score1、score2……score50 在 Python 眼里是50 个彼此独立的变量:每个名字各自绑定一块内存,名字与内存的对应是「点名式」的——想访问哪个,就必须在代码里把名字原样写出来。循环做不到这件事:循环需要「按位置批量访问」——给一个整数 i 就能拿到「第 i 个」,而这 50 个独立名字之间不存在任何可计算的对应关系,你无法从 score1 的位置算出 score2 在哪。
列表恰好补上了这块短板:50 个值装进连续格子之后,「第 i 个」变成了一个可计算的位置(10.4 节的「首地址 + 偏移」公式就是它的精确形式)。循环变量 i 从此有了意义——它就是偏移量,scores[i] 就是「跳到第 i 格取数」。所以数据结构的第一重意义,是把「名字寻址」升级为「位置寻址」,批量处理的大门由此打开。
再深一层:组织方式直接决定操作的成本。同一批数据,用列表装,随机访问快、中间插删慢;换成别的组织方式(见 10.4 节末尾的链表预告),插删快、随机访问慢。世界上没有「样样都快」的结构——选择数据结构,本质是看你最常做哪种操作,然后为高频操作挑一个便宜的结构。记住这条主线:这一章乃至将来任何数据结构课程,讲的其实都是「结构与成本的对应关系」。
📐 例题 1 用列表组织一周温度读数,求平均值与最高值
某试验台架连续 7 天记录环境温度(℃):12.5、13.0、11.8、14.2、15.0、13.7、12.9。请用列表存放这批读数,并用循环求出:① 平均温度;② 最高温度。
思路 「存一批」用列表;「算汇总」用第 8 章的累加器与找最值套路——total 从 0 起累加,highest 先拿第一个元素当基准,遍历中遇到更大的就替换。个数不要手数,用 len() 现场求。
解
temps = [12.5, 13.0, 11.8, 14.2, 15.0, 13.7, 12.9]
total = 0
highest = temps[0] # 先拿第一个当基准
for t in temps:
total = total + t # 累加
if t > highest: # 遇到更大的就更新
highest = t
average = total / len(temps)
print("平均:", average) # 平均: 13.3
print("最高:", highest) # 最高: 15.0
手工核算:累加过程依次为 12.5 → 25.5 → 37.3 → 51.5 → 66.5 → 80.2 → 93.1,共 7 个读数,平均值 = 93.1 ÷ 7 = 13.3 ℃;最高温度 = 15.0 ℃。
答:平均温度 13.3 ℃,最高温度 15.0 ℃。 ⚠ 易错点:① 除数用 len(temps)(= 7),若手滑写成 6,平均会偏高;② 找最值的基准必须取「第一个元素」而不是 0——本例全为正数看不出来,可一旦统计冬夜温度(可为负),以 0 为基准就会得出错误的最大值,第 12 章 12.4 节还会让这个坑当场现形;③ 累加器忘了初始化(total = 0)会直接 NameError。
辨析与衔接:变量 vs 数据结构 两者不冲突而是分工:第 4 章的变量解决「命名」——给一块内存起名字,方便人读写;本章的数据结构解决「组织」——把多个值按某种布局放在一起,让程序能按位置批量处理。变量是砖,列表是砌好的墙;墙能不能快速定位到某一块砖,取决于砌法——这正是 10.3 节要解剖的「内存布局」。
10.2列表 list:能装多个值的容器
Python 里最常用的数据结构是 列表(list),用方括号 [ ] 表示,里面用逗号隔开多个值:
scores = [88, 76, 95, 61] # 一个列表,装 4 个成绩
要取列表里的某个值,用下标(index),写在方括号里:
scores[0] # 第 1 个元素 → 88
scores[2] # 第 3 个元素 → 95
⚠️ 关键:下标从 0 开始! scores[0] 是第一个元素,不是第 0 个「不存在」的元素。下标 0 对应第一个,下标 n-1 对应第 n 个。这是新手最容易搞错的地方,也是接下来 10.4 节要解释的「为什么」。
列表的妙处在于:配合循环,你可以批量处理所有元素:
scores = [88, 76, 95, 61]
for s in scores: # s 依次等于 88、76、95、61
print(s)
🧠 一句话记忆 列表 = 方括号装多个值,下标从 0 开始。第一个是 [0],最后一个是 [长度-1]。
下标:是「编号」,更是「偏移」
日常数数习惯从 1 开始——「第 1 个、第 2 个」,所以新手看到 scores[0] 总觉得别扭。这里先给一个过渡性的理解:把下标看成「从起点偏移的步数」。scores[0] 是「从起点偏移 0 步」,自然落在第一个元素上;scores[2] 是「偏移 2 步」,跳过前两个元素、落在第三个上。10.4 节会把这句话升级成精确的地址公式;现在先带着「下标即偏移」的直觉往下走。
Python 还提供了负下标:从末尾往前数,-1 是最后一个、-2 是倒数第二个。于是「取最后一个」不必写 scores[len(scores) - 1],直接 scores[-1]。这个语法在「取最新记录」时特别顺手——日志列表里 logs[-1] 就是刚刚写入的最新一条。
📐 例题 2 下标访问、负下标与越界
已知 scores = [88, 76, 95, 61]。求 scores[1]、scores[3]、scores[-1]、scores[-2] 的值,并说明 scores[4] 会发生什么。
思路 正下标从 0 往后数:0→88、1→76、2→95、3→61;负下标从 −1(最后一个)往前数。长度 4 的列表,合法下标区间是 0~3 或 −4~−1。
解
scores = [88, 76, 95, 61]
print(scores[1]) # 76
print(scores[3]) # 61
print(scores[-1]) # 61(最后一个)
print(scores[-2]) # 95(倒数第二个)
print(scores[4]) # IndexError: list index out of range
答:76、61、61、95;scores[4] 越界,抛出 IndexError。 ⚠ 易错点:① 「下标 4」不是第 4 个元素——第 4 个是下标 3;② 合法下标最大到 len − 1:循环里写 range(len(scores)) 恰好安全,写 range(len(scores) + 1) 必越界;③ 越界不是「返回空值」而是直接抛异常——这正是第 11 章要学「异常处理」的原因之一。
另一个值得注意的 Python 特性:列表可以混装不同类型,[88, "缺考", 76.5, True] 完全合法,这与许多语言「数组只能装同一种类型」截然不同。原因藏在下一节:Python 列表的每个格子里放的不是值本身,而是一张张「写着地址的卡片」——地址的格式是统一的,至于地址那头是整数还是字符串,格子并不关心。
辨析:下标 vs 元素值 下标是「位置」,元素是「内容」,一一对应但绝不能混谈:scores[2] 中 2 是下标、95 是元素值,口诀是「方括号里是门牌号,取出来的是住户」。另外注意 10.5 节要讲的 in 查的是「值在不在列表里」,与下标无关——按位置找用下标,按内容找用 in,两件事别搅在一起。
10.3内存布局:数组是连续的一排格子
现在追到底层。Python 的列表,底层其实是 数组(array)。数组在内存里有个关键性质:它的元素是「连续」存放的——一个紧挨一个,中间不留空隙。
想象内存是一长串编号的格子,数组就是其中连续的一段:
「连续」是数组最重要的性质,它带来一个巨大的好处,也带来一个限制:
| 方面 | 结果 |
|---|---|
| 好处 | 只要知道开头地址和下标,就能瞬间算出任意元素的位置(随机访问) |
| 限制 | 想在中间插入/删除元素,要「挪动」后面所有元素,较慢 |
底层原理 数组是「连续内存 + 定长格子」的组合。这种物理上的紧挨,是后面一切高效操作(随机访问)的根源,也是低效操作(插入删除)的根源。理解「连续」二字,你就理解了数组的一半。
深挖一层:格子里放的到底是什么?
「连续格子」的说法已经很形象,但真实情况还要再深一层:Python 列表的每个格子里放的并不是值本身,而是一张「地址卡片」(引用)。88、76 这些数字对象实际住在内存的其他角落,连续排成一排的,是它们对应的地址。取 scores[2] 时,计算机其实走了两步:第一步,按公式跳到第 2 格,读出卡片上的地址;第二步,再跳到该地址,把值取出来。
「两步跳」回答了 10.2 节留下的问题——为什么能混装类型:格子里只存地址,地址的格式统一,地址那头是整数、字符串还是布尔值都无所谓。作为对照,C 等语言的「传统数组」格子里直接放值本身,因此必须把元素类型和大小一次定死,换来的是少跳一步、更快更省。两种设计各有取舍,但「格子连续 → 下标可算出格子位置」这个随机访问的根基,两者完全一致——这正是本节标题强调「连续」的原因。
连续布局还有一个不显眼的代价:整块申请。1000 个元素的列表要一次申请 1000 格连续内存,而内存用久了会被各种数据切得七零八落(碎片化),一长段连续空位未必好找。Python 的对策是「预留余量」:列表通常多申请一些格子,append 先填空位,真满了才整体搬家到一块更大的连续内存。搬家要复制全部元素,看似昂贵,但分摊到每次 append 上成本很低(10.5 节细算这笔账)。
📐 例题 3 插入与删除的「移动成本」
长度 1000 的列表 data = [0, 1, 2, …, 999](元素值恰好等于下标)。问:① 在下标 0 处 insert(0, "新") 要移动多少个元素?② 在正中间(下标 500)插入呢?③ 直接 append 到末尾呢?④ 若在 1000 个位置等概率随机插入一个元素,平均要移动多少个?
思路 连续布局决定了:在第 i 格塞进新元素,第 i 格及之后的所有元素都得整体向后挪一格腾位置,所以移动个数 = n − i(n 为原长度)。append 相当于 i = n,谁也不用挪。
解 ① i = 0:移动 1000 − 0 = 1000 个;② i = 500:移动 1000 − 500 = 500 个;③ append:移动 0 个;④ 移动个数从 1000 到 1 逐个取值且机会均等,平均 = (1000 + 999 + … + 1) ÷ 1000 = 500500 ÷ 1000 = 500.5 个,约等于长度的一半。用代码实测印证:
n = 1000
total_moves = 0
for i in range(n):
total_moves = total_moves + (n - i) # 在位置 i 插入要挪的元素数
print(total_moves / n) # 500.5
data = list(range(1000)) # [0, 1, 2, …, 999]
data.insert(500, "新")
print(len(data)) # 1001
print(data[500], data[501], data[1000]) # 新 500 999
插入后 data[500] 是新元素;原来位于下标 500~999 的元素整体后移成 data[501]~data[1000]——移动了整整 500 个,与公式 n − i = 1000 − 500 = 500 一致。
答:① 1000 个;② 500 个;③ 0 个;④ 平均 500.5 个(≈ 一半)。 ⚠ 易错点:① 第 i 格「腾位」也要把原来占在第 i 格的元素挪走,所以是 n − i 而不是 n − i − 1;② append 一个都不挪——「能加末尾就加末尾」的性能习惯由此而来;③ 删除同理反着来:删掉第 i 个元素,后面的 n − i − 1 个都要向前补位,头删(i = 0)同样最贵。
📖 工程视角:插删昂贵,工程上怎么绕? 工程数据大多是「只追加、不改动」:传感器按时间顺序不断 append 新读数,事后只做查询与统计,几乎从不在中间插数据——这正好避开了数组最贵的操作、用满了最便宜的操作。真要频繁在中间插删时,工程上有两招:换用链表(10.4 节末预告),或「逻辑删除」——给要删的数据打个标记,遍历时跳过,避免真实挪动。数据库与日志系统里大量用这类「先标记、后清理」的思路,本质都是顺着结构的成本标签设计用法。
辨析:好处与限制同根同源 本节开头那张「利与弊」表,两行其实是同一个原因的两面:因为连续,位置可算,所以随机访问快;也因为连续,中间插删会「挤坏队形」,必须挪动。理解到这一层就不必背表——抓住「连续」二字,利弊自己就能推出来。
账单之外:remove 与 del 这两个亲戚
除 pop 之外,删元素还有两个常用亲戚,成本结构各不相同:remove(值) 是「按内容删」——先从头扫描找到第一个等于该值的元素(平均翻一半,O(n)),找到后还要挪动补位(又是 O(n)):一次 remove,两笔账单;del scores[i] 是「按下标删」,省去查找、只付挪动,和 pop(i) 一回事,区别只是 del 不返回被删的值、pop 会把值交给你。顺带一提 clear()——整表清空,与元素个数无关,O(1)。把它们与 pop 排在一起看:「按内容找」永远比「按位置取」贵一层,这个规律在字典、集合上同样成立,是贯穿数据结构的通则。
# 三种删除方式的账单对照(成本按 n=列表长度计)
scores = [88, 76, 95, 61, 70]
scores.remove(76) # 按内容删:先扫到 76 在下标1,再把它后面的 95、61、70 各前移一格
del scores[0] # 按下标删:不找,直接把 88 后面的所有元素前移一格
scores.pop() # 删末尾:谁都不用挪(删掉的是 70)
print(scores) # [95, 61]
辨析:remove、del、pop 怎么选 口诀是「知道位置就别按内容找」。知道下标、还想要回被删的值 → pop(i);知道下标、不要值 → del scores[i](还能整段切片删除,如 del scores[1:3],切片下章见);只知道内容 → 只能 remove(值),认下两笔账。删末尾几乎免费、删开头最贵——同样是「删」,成本差出 n 倍,这提醒我们:读代码时看到 remove 与 del 开头,就该意识到「这里有挪动」。
10.4为什么能「随机访问」
「随机访问」(random access)意思是:不按顺序翻找,直接一步跳到任意下标取到值。比如 scores[2] 不管列表多长,都是「瞬间」取到。
这背后的原理,就是一道简单的地址换算公式。假设:
- 数组的首地址(第一个元素的位置)是 100;
- 每个元素占 1 格(大小固定);
- 那么下标为 i 的元素,地址 = 首地址 + i。
底层原理 「随机访问」不是魔法,而是「连续存储 + 一次地址加法」。因为数组连续,所以「第 i 个元素」的位置可以用公式直接算出来,不必逐个查找。这也是为什么下标从 0 开始:下标 0 意味着「偏移 0」,地址 = 首地址 + 0,最自然。
🧠 一句话记忆 随机访问 = 首地址 + 下标,一次加法直达。下标从 0 起,因为「第一个元素的偏移就是 0」。
把公式一般化:地址 = 首地址 + 下标 × 每格大小
图 10-2 里每格大小取的是 1;真实内存中一个元素往往占好几个字节,把公式一般化:元素地址 = 首地址 + 下标 × 每格字节数。一次乘法一次加法,与列表长度无关——这就是「O(1)」的准确含义:不管列表有 4 个还是 400 万个元素,定位任意一个都只花这套固定的小算术。与之相对,从头逐个翻找平均要翻一半、最坏翻到底,元素越多越慢,成本随规模 n 线性增长,记作 O(n)。大 O 记号(big-O)此刻只需记直觉:它描述成本随数据规模增长的趋势——O(1) 是「恒定、不涨」,O(n) 是「跟着 n 线性涨」。往后学到的每种数据结构、每种操作,都可以贴这样一张成本标签,选结构就是选标签。
📐 例题 4 地址换算
某数组首地址为 2000,每个元素占 8 字节。求:① 下标 5 的元素地址;② 下标 0 的元素地址;③ 地址为 2072 的元素,下标是多少?
思路 套公式「地址 = 2000 + 下标 × 8」;第 ③ 问反向运用:(地址 − 首地址) ÷ 每格字节数 = 下标。
解 ① 2000 + 5 × 8 = 2000 + 40 = 2040;② 2000 + 0 × 8 = 2000——再次印证「下标 0 就是不偏移」;③ (2072 − 2000) ÷ 8 = 72 ÷ 8 = 9。
答:2040、2000、下标 9。 ⚠ 易错点:① 偏移要乘「每格字节数」,漏乘会差好几倍;② 反算必须整除——72 ÷ 8 = 9 恰好整除,若算出带小数(如 73 ÷ 8 = 9.25),说明那个地址根本不是某个元素的起始地址,别硬凑答案。
顺手回答一个经典之问:「如果下标从 1 开始会怎样?」公式会变成「地址 = 首地址 + (下标 − 1) × 大小」,每次访问都多一次减法。今天看这一次减法微不足道,但在计算机资源寸土寸金的年代,省掉它是实打实的收益,于是「从 0 开始」成了主流传统并延续至今。更本质地说:下标天生就是偏移量,偏移的自然起点就是 0——「从 0 开始」不是怪癖,而是下标本义的直接体现。
预告:链表——把成本标签整个换掉
随机访问快是数组的招牌,插删要挪动是它的死穴。有没有组织方式恰好反过来——插删便宜、查找变贵?有,就是链表(linked list)。它不追求连续:每个元素独住一间「小屋」(节点),屋里除了值,还放着一张「下一跳地址」卡片指向下一个节点,整串数据靠卡片串起来。找元素必须从头节点出发一跳一跳数过去;但插删只需把附近一两个节点的卡片改个地址,其余节点纹丝不动。图 10-3 把两种布局画在一起对照。
| 操作 | 数组(连续) | 链表(指针串联) |
|---|---|---|
| 按下标取元素 | O(1):公式直达 | O(n):从头数过去 |
| 中间插入/删除 | O(n):后面整体挪动 | O(1)*:改一两个指针 |
| 内存要求 | 需要一整段连续空间 | 节点可散落各处 |
两点说明 ① 链表插删 O(1) 的前提是「已经站在那个位置」——先把指针挪过去本身要 O(n),改指针才是 O(1),所以常说「已知位置时插删 O(1)」;② 10.2 节说 Python 列表能混装类型、传统数组不能——现在能对上了:Python 格子放的是格式统一的地址卡片,传统数组格子放的是类型定死的值本身。结构设计没有对错,只有取舍:每一样「得到」,都是用另一样「换来的」。
把成本表翻译成「什么时候用谁」的口诀,这组对比才算真正落地。按下标频繁取数、总长相对稳定、要一口气遍历全部——用数组:地址公式一步直达,连续内存还附赠「缓存友好」的隐藏福利(硬件一次能搬一整块);频繁在任意位置插删、总长涨落无常、内存碎片较多——用链表:定位之后改一两个指针完事,不必腾整段连续空间。放进场景里感受一下:查「第 37 号储位的灯亮不亮」,数组一步算出地址;维护一份「随时要在任意位置插入或撤销」的排班序列,链表改动局部到位。再送一条工程直觉:日常程序里数组/列表是默认选项,链表只在「插删压倒一切」的场景出场——因为链表的指针跳来跳去,对缓存反而不友好。选型从来不是背表格,而是问自己一句:我的程序里哪种操作最频繁?让最频繁的那个享受 O(1)。
10.5列表的常用操作
列表有一批常用操作,这里挑最实用的几个:
| 操作 | 代码 | 结果/说明 |
|---|---|---|
| 求长度 | len(scores) | 元素个数,如 4 |
| 末尾追加 | scores.append(70) | 变成 [88,76,95,61,70] |
| 按下标改 | scores[1] = 80 | 变成 [88,80,95,61] |
| 删除元素 | scores.pop(0) | 删掉下标 0,返回 88 |
| 是否包含 | 95 in scores | 返回 True |
结合循环,列表能做很多事。比如求平均分:
scores = [88, 76, 95, 61]
total = 0
for s in scores:
total = total + s # 累加(第 8 章学的累加器)
average = total / len(scores)
print(average) # 输出:80.0
要点 列表 + 循环是数据处理的核心组合:用循环遍历列表,用累加器汇总。求总和、平均值、最大值,套路都一样——先初始化,再遍历累加/比较。
给每个操作贴上「成本标签」
常用操作表之外,还藏着一张更重要的「成本表」,逐个分析:len(scores)——长度记在列表自己的「户口本」上,读一下就返回,O(1);append——末尾追加谁也不挪,O(1)(偶尔碰上整体搬家,均摊下来仍是 O(1),10.3 节的「预留余量」正是为此);scores[i] 读或写——公式直达,O(1);insert(i, x) 与 pop(i)——中间插删要挪后面所有元素,O(n);95 in scores——无从计算、只能从头一个个比,O(n)。把这张账记在心里,写代码时自然就有了轻重。
从成本表能读出两条工程习惯:能 append 就不 insert——往末尾加是列表最便宜的动作,第 12 章项目里的成绩表就是纯 append 组织的;能按下标取就别用 in 找——in 是全表扫描,列表很长时反复 in 会明显拖慢。图 10-4 直观展示了 insert 的「挪人」过程。
📐 例题 5 一串操作的逐步追踪
scores = [88, 76, 95, 61],依次执行 scores.append(70) → scores.pop(0) → scores[1] = 90。写出每步之后列表的样子;最后求 len(scores)、平均分,并判断 95 in scores。
思路 铺一张草稿纸,每做一步就把列表重抄一遍——这种「追踪」(trace)是阅读与调试代码的基本功,第 12 章讲调试方法时还会靠它。
解
scores = [88, 76, 95, 61]
scores.append(70) # [88, 76, 95, 61, 70]
scores.pop(0) # [76, 95, 61, 70](删掉了 88)
scores[1] = 90 # [76, 90, 61, 70]
total = 0
for s in scores:
total = total + s
print(len(scores)) # 4
print(total / len(scores)) # 74.25
print(95 in scores) # False
核算:76 + 90 + 61 + 70 = 297,297 ÷ 4 = 74.25。注意 95 的行踪:append、pop 之后它都还在,直到 scores[1] = 90 把它覆盖掉,in 才变 False。
答:列表依次为 [88, 76, 95, 61, 70] → [76, 95, 61, 70] → [76, 90, 61, 70];长度 4,平均分 74.25,95 in scores 为 False。 ⚠ 易错点:① 删除与覆盖是两回事——pop(0) 删的是 88,95 是被后来的赋值「顶掉」的;② 追踪题最忌凭印象跳步,一步步抄下来才不会张冠李戴;③ 若数据换得除不尽(如 297 ÷ 4 恰好整除是特例),浮点结果可能带一长串小数,核对时按第 5 章讲的浮点显示规律理解。
📖 工程视角:滚动日志的标准姿势 设备监控脚本最常见的诉求是「保留最近 N 条记录」:新读数来了 append 到末尾;列表一旦超长,删掉最旧的或整段截断。append 便宜,超限才做一次批量清理——这种「攒一批再处理」的节奏,正是顺着列表的成本标签设计的。反过来,若每来一条新数据都 insert 到开头(最贵的位置),一天 86400 条读数就要白白挪动数千万次元素——结构没选错、用法用错了,照样翻车。
辨析:pop()、pop(i) 与 in 的分工 pop() 不带参数删末尾,O(1);pop(0) 删开头,O(n)——一个参数之差,成本天壤之别。「删最新的」用 pop();「删最老的」要么认下 pop(0) 的代价,要么换一种组织方式(比如记录按新到旧排)。「找内容」才用 in,「找位置」永远用下标。写代码时多问一句「这一步谁在挪动、挪多少」,性能直觉就长在身上了。
10.6其他结构:元组与字典
列表之外,还有两个常用的数据结构,先混个脸熟:
元组 tuple:不能修改的列表
元组用圆括号,和列表几乎一样,但一旦创建就不能修改(不能增删改元素)。
point = (3, 5) # 一个坐标点
point[0] # 3
# point[0] = 9 # 报错!元组不可修改
字典 dict:按「名字」取值,而不是下标
列表靠「下标(0,1,2…)」找元素;字典靠「键(名字)」找元素,用花括号:
student = {"name": "小明", "age": 20, "score": 88}
student["name"] # → "小明",用键"name"取到值
| 结构 | 符号 | 怎么取值 | 能否修改 |
|---|---|---|---|
| 列表 list | [ ] | 下标 [0] | 能 |
| 元组 tuple | ( ) | 下标 [0] | 不能 |
| 字典 dict | { } | 键 ["name"] | 能 |
📖 一句话理解三种结构 列表像「编号的格子」——按位置取;元组像「封好的包裹」——内容固定不变;字典像「查字典」——按名字(键)查到含义(值)。各有各的用武之地,列表最常用,先把列表吃透。
为什么要有「不能改」的元组?
新手常有疑问:既然有了列表,为什么还发明一个「残废版列表」?因为不可修改不是缺陷,而是承诺。元组从创建到销毁内容永不改变,这带来两个实打实的好处:其一,它可以放心地当「钥匙」用——字典按键取值(下一小节),键必须是「不可变」的东西,列表随时可变、没资格当键,元组正合适;其二,多人协作时,「不该动的数据」放进元组等于上了锁,谁也别想悄悄改它。坐标 (x, y)、一组常量、函数的多个返回值,都适合用元组表达「生来如此、别动我」。
顺带揭一个伏笔:第 9 章讲过 return 把结果吐还给调用方;这里更进一步——return 后面可以一次列多个值,写 return average, highest, lowest, passed——Python 在背后打包的正是一个元组:四个值捆成一个不可变包裹交出去,调用方再拆包成四个变量。第 12 章项目里 compute_stats 返回四个统计值,用的就是这一手。
字典为什么「按名字」也能瞬间取到
按直觉,「按名字查」似乎得把整本字典翻一遍?恰恰不用。字典的底层叫哈希表(hash table):对键做一次数学换算(哈希函数),直接算出这个键该住在哪个「桶」里。是不是很眼熟?列表是「下标 → 地址」一步算出,字典是「键 → 桶地址」一步算出——字典可以看作「下标从整数推广成任意键」的列表,「可计算的位置」被推广成了「可计算的键」。代价是键必须「可哈希」(本质上就是不可变),这正好呼应上一段:元组能当键、列表不能。至于哈希函数怎么设计、桶冲突怎么处理,属于后续课程;此刻记住「按键直达、平均 O(1)」即可。
📐 例题 6 用字典组织测点读数
试验台上有 3 个测点,读数(MPa)存在字典 readings = {"pump_a": 3.2, "pump_b": 4.8, "valve_c": 2.5} 中。求:① 取出 pump_b 的读数;② 三个测点的平均读数;③ 判断 "pump_a" 是不是已有测点;④ readings["pump_x"] 会发生什么?
思路 按键取值直接用方括号;求平均要遍历所有值——用 for v in readings.values() 依次取出每个值,套累加器模板;「键在不在」用 in,注意它查的是键不是值。
解
readings = {"pump_a": 3.2, "pump_b": 4.8, "valve_c": 2.5}
print(readings["pump_b"]) # 4.8
total = 0
for v in readings.values():
total = total + v
print(total / len(readings)) # 3.5
print("pump_a" in readings) # True(查的是键)
print(readings["pump_x"]) # KeyError: 'pump_x'
核算:3.2 + 4.8 + 2.5 = 10.5,共 3 个测点,平均 = 10.5 ÷ 3 = 3.5。
答:pump_b 读数 4.8;平均读数 3.5;"pump_a" 是已有测点(True);readings["pump_x"] 抛 KeyError。 ⚠ 易错点:① len(字典) 数的是键值对(测点)的个数,不是别的;② 遍历 readings 本身得到的是键,遍历 readings.values() 得到的才是值——求和前先想清楚要哪一头;③ 方括号按键「直取」,键不存在不是返回 None 而是抛 KeyError——这又是第 11 章异常处理要接的对象之一。
📖 工程视角:设备台账用字典 工程里「按编号查参数」的需求俯拾皆是:设备台账(编号 → 参数)、工具清单(型号 → 校验日期)、传感器映射(通道号 → 量程)。这类数据有天然的「名字」、没有天然的「顺序」,正是字典的主场。把第 12 章的成绩项目稍加改造——学号 → 成绩,就是一台小设备台账;两种结构怎么换、换了之后哪段代码要跟着改,值得你亲手试一遍。
辨析与衔接 列表按位置、字典按键,表面是两种结构,底层是同一个思想:「用可计算的键,一步直达值」。位置是整数键,键是任意的不可变值。选型口诀:有天然顺序用列表,有天然名字用字典。「列表套字典」「字典套列表」这类组合(比如 50 个学生每人一条记录)在真实工程数据里最常见——结构可以搭积木,规则就是本章这两条。
元组的两个书写陷阱
元组书写上有个出了名的陷阱:单元素元组必须带逗号。("A",) 是元组,("A") 只是一对括号包着的字符串,Python 根本不会当元组看——括号在这里是「改变运算优先级」的数学括号,逗号才是元组的灵魂。同理 (5) 是整数 5,(5,) 才是「装着一个 5 的元组」。空元组没有元素、无逗号可带,写作 ()。另一条顺手记:已有列表可以一键转换成元组上锁——tuple(scores) 返回内容相同的元组;反过来 list(某元组) 解锁。真机上可以亲手验一遍:
t = ("A",) # 元组:括号里一个元素 + 一个逗号
x = (5) # 不是元组!这就是整数 5
print(type(t)) # <class 'tuple'>
print(type(x)) # <class 'int'>
print(len(t)) # 1
u = (88, 76)
print(u[0], u[1]) # 88 76 —— 元组同样用下标取值,只是不能赋值
本章小结
- 数据结构是组织和管理一群数据的方式,解决「存一堆 + 有序 + 可批量处理」。
- 列表 list 用
[ ]装多个值,靠下标取值,下标从 0 开始。 - 列表底层是数组,元素在内存里连续存放。
- 随机访问的本质是「首地址 + 下标」一次加法直达,无需遍历。
- 元组(不可改)、字典(按键取值)是另外两种常用结构。
- 下标即偏移:元素地址 = 首地址 + 下标 × 每格字节数,随机访问是 O(1);中间插入/删除要挪动后面所有元素,是 O(n)。
- 选结构就是选成本标签:能 append 就不 insert,知道位置用下标、只知道内容才用 in;链表把这张标签整个反过来。
- 删法账单:删末尾 O(1)、按下标删付挪动 O(n)、按内容 remove 再加查找一笔;单元素元组必须写成 (5,),逗号才是元组的灵魂。
读完本章,你应该能……
- 创建一个列表,并用下标取出第 1 个和最后一个元素。
- 说出「下标从 0 开始」的原因(偏移量为 0)。
- 用循环遍历列表,求出一组数的总和与平均值。
- 解释「随机访问」为什么能瞬间完成。
- 区分列表、元组、字典三者的符号和取值方式。
- 用「追踪法」手写出一串操作(append、pop、按下标改值)之后列表的样子。
- 算一笔地址账:给定首地址与每格字节数,求某下标元素的地址,或反推下标。
- 说清 insert 与 append 的成本差异,并举一个「字典比列表更合适」的工程例子。
思考题
1. 列表 [10, 20, 30, 40] 的长度是多少?list[3] 是多少?
提示:长度 4;下标 3 是第 4 个元素,即 40。
2. 为什么数组能「随机访问」,而无需从头一个个找?
提示:因为连续存储,用「首地址 + 下标」直接算出地址,一次加法直达。
3. 为什么下标从 0 开始,而不是从 1 开始?
提示:下标表示「偏移量」,第 0 个元素的偏移是 0,地址 = 首地址 + 0,最自然。
4. 用列表和循环,写一个程序找出 [3, 9, 1, 7] 中的最大值。
提示:设 max_val = 第一个元素,遍历时若遇到更大的就更新。
5. scores = [10, 20, 30, 40, 50],scores[-2] 是多少?
提示:负下标从末尾数起,-1 是 50,-2 是倒数第二个 40。
6. 长度 1000 的列表,在下标 0 处 insert 一个元素大约要挪动多少个?末尾 append 呢?
提示:头插要挪动全部 1000 个;append 一个不挪——所以「能加末尾就加末尾」。
7. 同样是「找元素」,scores[i] 和 x in scores 的成本差别在哪?
提示:下标访问是公式直达 O(1);in 只能从头扫描 O(n)。知道位置用下标,只知道内容用 in。
8. 「按下标查固定编号的储位状态」和「在任意位置频繁插删的排班序列」,分别倾向数组还是链表?为什么?
提示:前者按下标直达,公式一步算出地址,数组 O(1);后者任意位置插删频繁,定位之后改一两个指针就地完成,链表更合适。让最频繁的操作享受低成本,就是选型的全部依据。
9. 链表「插删 O(1)」为什么常被误读?「走到位置」和「改指针」这两笔账怎么分开算?
提示:O(1) 只算「改指针」这一下,前提是已经站在目标位置;从头走到那里本身是 O(n)。两笔账分开记:定位 O(n)+挂接 O(1)——这正是 10.4 表格里 O(1) 带星号的含义。