← 返回知识卡片总目录

DS1号 · 自学教材系列 | 编程

第 10 章 数据结构入门

前面一个变量只能存一个数,可现实的数据往往是一大串:全班 50 个成绩、一本书的所有章节、购物车里的一堆商品。这一章讲 数据结构——怎么把「一群数据」组织起来。我们会聚焦最基础的列表/数组,并看清它在内存里的样子,理解那个看似神秘的性质:「为什么能按下标瞬间取到任意一个」。

🎯 本章学习目标

  1. 理解「数据结构」就是组织和管理一群数据的方式。
  2. 掌握 Python 列表 list 的创建、下标访问和常用操作。
  3. 理解数组在内存里的「连续」布局,以及下标从 0 开始的原因。
  4. 理解「随机访问」的本质:下标 → 地址的数学换算。
  5. 初步认识字典、元组等其他常用结构。

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。

📖 工程视角:工程数据总是成批出现 工程现场几乎不存在「孤立一个」的数据:液压系统的压力传感器每秒上报一个读数,一天就是 86400 个数;一次结构静力试验的应变测点动辄上百上千;设备的故障日志逐条累积。工程软件的第一步往往就是把这些成批数据组织好——采样序列按时间排进列表,再批量统计、批量回放。可以说,数据结构是工程数据处理的「容器学」:先选对容器,后面的算法才谈得上效率。

辨析与衔接:变量 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)。数组在内存里有个关键性质:它的元素是「连续」存放的——一个紧挨一个,中间不留空隙。

想象内存是一长串编号的格子,数组就是其中连续的一段:

数组在内存中的连续布局 数组的4个元素在内存里连续存放,每个占一格,下标从0到3。 数组 scores = [88, 76, 95, 61] 在内存里 88 76 95 61 下标 0 下标 1 下标 2 下标 3 地址:100 101 102 103 …(连续!每个元素占一格,紧挨着)
图 10-1数组的元素在内存里连续存放、紧挨着。每个元素对应一个下标(0 到 3)。

「连续」是数组最重要的性质,它带来一个巨大的好处,也带来一个限制:

「连续」带来的利与弊
方面结果
好处只要知道开头地址和下标,就能瞬间算出任意元素的位置(随机访问)
限制想在中间插入/删除元素,要「挪动」后面所有元素,较慢

底层原理 数组是「连续内存 + 定长格子」的组合。这种物理上的紧挨,是后面一切高效操作(随机访问)的根源,也是低效操作(插入删除)的根源。理解「连续」二字,你就理解了数组的一半。

深挖一层:格子里放的到底是什么?

「连续格子」的说法已经很形象,但真实情况还要再深一层: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] 不管列表多长,都是「瞬间」取到。

这背后的原理,就是一道简单的地址换算公式。假设:

下标到地址的换算 通过公式 首地址+下标 直接算出任意元素地址,实现随机访问。 scores[2] 是怎么「瞬间」找到 95 的? 已知:首地址 = 100 每格大小 = 1 下标 2 的地址 = 100 + 2 = 102 读地址 102 → 得到 95 → → 一次「加法」就算出地址,直接去取——不用从头一个个翻 这正是下标从 0 开始的原因:第 0 个元素的偏移是 0,地址 = 首地址 + 0
图 10-2随机访问的本质:用「首地址 + 下标」一次加法算出地址,直接取到元素,无需遍历。

底层原理 「随机访问」不是魔法,而是「连续存储 + 一次地址加法」。因为数组连续,所以「第 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 把两种布局画在一起对照。

数组与链表的存储布局对比 数组元素在内存里连续排列,下标可直接换算地址;链表节点散落各处,靠指针一跳一跳连接。 数组:连续一排 → 下标直达 88 76 95 61 下标0 下标1 下标2 下标3 内存里手拉手紧挨着 →「首地址 + 下标」一次加法直达任意一格 链表:散落各处 → 指针串联 88 • 76 • 95 • 值 +「下一跳」指针同住一屋;想找第 3 个?只能从头一跳一跳数过去
图 10-3数组(上)与链表(下)的存储布局对照:连续格子换来「下标直达」,指针串联换来「插删只改地址」。
数组 vs 链表:成本标签对照
操作数组(连续)链表(指针串联)
按下标取元素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的列表在下标2插入新元素25,原下标2到4的元素30、40、50依次后移一格。 在 [10, 20, 30, 40, 50] 的下标 2 处 insert(2, 25) 10 20 30 40 50 0 1 2 3 4 ↓ 新元素要插进下标 2,30、40、50 无处可站,只能整体后移 10 20 25 30 40 50 0 1 2(新) 3 4 5 挪动个数 = n − i = 5 − 2 = 3 个:30 → 3 号格、40 → 4 号格、50 → 5 号格
图 10-4在下标 2 插入元素:30、40、50 依次向后挪一格腾位,新元素落座下标 2。挪动个数 = n − i,这正是插入操作 O(n) 成本的来源。

📐 例题 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 —— 元组同样用下标取值,只是不能赋值

本章小结

  1. 数据结构是组织和管理一群数据的方式,解决「存一堆 + 有序 + 可批量处理」。
  2. 列表 list 用 [ ] 装多个值,靠下标取值,下标从 0 开始。
  3. 列表底层是数组,元素在内存里连续存放。
  4. 随机访问的本质是「首地址 + 下标」一次加法直达,无需遍历。
  5. 元组(不可改)、字典(按键取值)是另外两种常用结构。
  6. 下标即偏移:元素地址 = 首地址 + 下标 × 每格字节数,随机访问是 O(1);中间插入/删除要挪动后面所有元素,是 O(n)。
  7. 选结构就是选成本标签:能 append 就不 insert,知道位置用下标、只知道内容才用 in;链表把这张标签整个反过来。
  8. 删法账单:删末尾 O(1)、按下标删付挪动 O(n)、按内容 remove 再加查找一笔;单元素元组必须写成 (5,),逗号才是元组的灵魂。

读完本章,你应该能……

思考题

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) 带星号的含义。