图就是一堆点加一堆连线,点与点之间没有上下级,只有「连没连」。 树是图的特例:树不能有圈,图可以有。 下标从 0 还是 1 是约定不是道理,混着用才是灾难——错的那次往往还不报错。
这一篇是第 3 章的收尾,讲两件事:图这个形状,和专题 3 的下标之争。 两件事放在一起, 是因为它们都在讲「关系」和「编号」——一个是数据之间的关系,一个是数据被编上号之后的坑。 图讲完,第 3 章的五种结构就齐了:数组、链表、堆栈队列、树、图。下一章开始讲算法本身。
一、图就是点加线
五个点 A 到 E,五条线:A 连 B、A 连 C、B 连 D、C 连 D、D 连 E。 画出来是一张网。没有根,没有上下级,只有「连没连」。 每个点连着几条线,叫它的度。演示里数了一遍:A 是 2,D 是 3,E 是 1。
这些数不是摆设。度大的点是枢纽,删掉它整个图可能就断成两半——网络规划、路网优化,看的都是这个。 教材说地图、社交关系、地铁线路都是图。共同点是:「谁和谁有关」比「谁比谁大」重要。
二、树是图的特例
树其实就是图,只是多两条限制:不能有圈,而且任意两点之间只有一条路。 所以树能说的「父子关系」,图说不了; 图能说的「互相连着」,树也经常说不了(树里两点之间只有一条路,图里可以有好几条)。 这个包含关系值得记住:树 ⊂ 图。所有对图成立的算法,在树上都能用;反过来不成立。
反过来说也成立:如果一个数据本来有层级,却硬按图来存,你会丢掉「谁是谁的上级」这条现成的信息, 每次都要重新算出来。能用树就别用图。
三、从 A 出发能走到哪些点
演示从 A 出发,走遍全部五个点,顺序是 A、B、C、D、E。 这个走法叫广度优先:先把一圈邻居全收进来,再收邻居的邻居。 它靠一个队列实现——先进先出,所以同一层的点会挨着处理。正是 3.2 那个队列。
换成堆栈就成了深度优先:一条路走到黑,走不动了再回头。同一张图,换个容器,走法完全不同。 这也是数据结构真正的位置:它不是主角,是算法的零件。选错容器,算法就换了性格。
四、专题 3:下标从 0 还是从 1
演示里那个小例子最能说明问题:days 存周一到周日。
从 0 开始时 days[0] 是周一;从 1 开始时 days[1] 是周二。
同一句「第 1 个」,两种约定给出两个答案。
这就是 off-by-one(差一错)的来源,也是所有下标错的统一名字。
想取「第 5 个」写成 days[5],报 IndexError: list index out of range;
正解是 days[4],星期五。
| 语言/场景 | 起始下标 | 原因 |
|---|---|---|
| C、Java、Python | 0 | 地址等于首地址加下标乘元素大小 |
| Fortran、MATLAB | 1 | 当年给数学家和工程师用 |
| Excel 单元格 | 1 | 人看的第一行就是第 1 行 |
| 书页码 | 1 | 没有第 0 页 |
五、没有哪个更对,只有跟谁一致
从 0 开始不是「先进」,是为了省一次减法:地址能直接用下标乘出来。 从 1 开始也不是「落后」,是贴近人的数法。 所以这不是对错问题,是跟谁一致的问题。一门语言内部一致、一个项目内部一致,就不会出事。 真正会出事的是混着用:同一份数据,一半地方当下标 0 起、一半地方当 1 起。早晚错一次。
而且错的那次往往不报错——只是少算了一行、跳过了第一个。报错反而是好事,不报错才难查。 所以写循环时的第一条纪律:下标的范围和边界,先在纸上写清楚再写代码。
六、动手 Demo:一张五个点的图,加上下标 0 与 1 的对照
先用邻接表把五个点的图存起来,数一数每个点连着几条线。 再从 A 出发广度优先走一遍,看能不能走到全部五个点。 最后把「第 1 个」在下标 0 和下标 1 两种约定下各试一次。
from collections import deque
g = {'A': ['B', 'C'], 'B': ['A', 'D'], 'C': ['A', 'D'],
'D': ['B', 'C', 'E'], 'E': ['D']}
for k in g:
print(k, '连着', g[k], '共', len(g[k]), '条')
seen, q, order = {'A'}, deque(['A']), []
while q:
v = q.popleft()
order.append(v)
for w in g[v]:
if w not in seen:
seen.add(w)
q.append(w)
print('广度优先', ' -> '.join(order), '共', len(seen), '个点')
days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
print('下标 0 起:第 1 个是', days[0])
print('下标 0 起:第 5 个是', days[4])
print('下标 1 起:第 1 个是', days[1])
try:
days[5]
except IndexError as e:
print('写 days[5] 会报错:', e)
实跑输出:
--------------------------------------------------------------------
图,以及下标从 0 还是 1
--------------------------------------------------------------------
① 图就是一堆点加一堆连线
--------------------------------------------------------------------
A-B A-C B-D C-D D-E
这个图有 5 个点、5 条边(每条算一次)
| 点 | 连着谁 | 几条线 |
|---|---|---:|
| A | B、C | 2 |
| B | A、D | 2 |
| C | A、D | 2 |
| D | B、C、E | 3 |
| E | D | 1 |
树是图的一种特例:树不能有圈,图可以有。地图、社交关系、地铁线路都是图。
② 从 A 出发,能走到哪些点
--------------------------------------------------------------------
广度优先走一遍:A -> B -> C -> D -> E
能到达 5 个点(全部)
先把一圈邻居都收进来,再收邻居的邻居——这就是广度优先。换成堆栈就变成一条路走到底,那是深度优先。
③ 下标从 0 还是从 1,差别在哪
--------------------------------------------------------------------
从 0 开始:第 1 个是 days[0] = 周一
从 1 开始:第 1 个是 days[1] = 周二
同一句「第 1 个」,两种约定给出两个答案。这就是 off-by-one 的来源。
想取「第 5 个」写成 days[5]:IndexError: list index out of range
正解是 days[4] = 周五
| 语言/场景 | 起始下标 | 原因 |
|---|---|---|
| C、Java、Python | 0 | 地址等于首地址加下标乘大小 |
| Fortran、MATLAB | 1 | 当年给数学家和工程师用 |
| Excel 单元格 | 1 | 人看的第一行就是第 1 行 |
| 书页码 | 1 | 没有第 0 页 |
没有哪个更对,只有「跟谁一致」。
混着用才是灾难——同一份数据两套下标,早晚会错一次,而错的那次往往不报错,只是少算了一行。
①g 这个字典叫邻接表:每个点当键,值是「它连着谁」。图最常用的存法就这一种。
②广度优先那段里 seen 必须在入队时就记上,不能等出队。否则同一个点会被好几个邻居重复塞进队列。
③走出来的顺序 A B C D E 正好是按层:先 A,再 A 的邻居 B、C,再 B、C 的邻居 D,最后 E。
④最后一行那个下标 5 故意留在代码里——它越界了,正确写法是 4。这就是 off-by-one 最常见的样子。
30 秒实验
- 把
deque换成堆栈(q.pop()),看走出来的顺序变成什么,那就是深度优先。 - 给图加一条 E 连 A 的线,看广度优先的结果还一样不一样。
- 手算一下:
days有 7 个元素,合法下标是 0 到几?把它写下来再验证。
常见误区
- 「图比树高级。」树是图的特例,有层级的数据用树更省事。能用树就别用图。
- 「下标从 0 开始是编程语言的怪癖。」是为了省一次减法,地址能直接乘出来。
- 「下标错会报错,好查。」很多下标错不报错,只是少算一行。这才是它讨厌的地方。
自测三问(点开对答案)
1. 树和图的关系是什么? —— 树是图的特例:不能有圈,两点之间只有一条路。树 ⊂ 图。 2. 广度优先和深度优先的差别在哪? —— 装点的容器不同。队列(先进先出)是广度,堆栈(后进先出)是深度。 3. 为什么说下标从 0 还是 1 是「约定」不是「道理」? —— 两种都自洽,各自有理由。危险的是同一份数据里混着用,错的那次往往还不报错。第 3 章五种结构到此齐了。 下一章开始动手写算法本身:从最小的样子——循环加累加——开始,一路做到排名和时间差。
- ☐ (本篇完,下一篇 4.1 循环:算法的基础)