圆方树不是“再给图换一套很玄的名字”。它处理的是一个很朴素的尴尬:两个结实的小团体可以共用同一个人;把人硬塞进互不重叠的组里,反而把信息弄丢了。今天我们先把这件尴尬画出来,再给“团体”发一张方形身份证。
学完后,你能:
前置:无向图、DFS 树、桥和割点的直觉。不会写 Tarjan 也没关系;本节会先用暴力删除验证。
请在纸上画下面的无向图;边都是双向走廊:
三角形一:A-B-C-A
三角形二:C-D-E-C
走廊链: C-F-G
纸上分四栏:对象(现在有哪些房间、走廊)、状态(删掉谁、DFS 到哪)、动作(本轮能沿哪条走廊)、账本(哪些点属于哪些结实团体)。每次遇到“停顿”,先写答案,再展开或运行程序。
贯穿问题不是“背出圆方树定义”,而是:从 A 去 E、从 A 去 G 时,哪些门卫绝对绕不过?如果后来要问很多次路径,能否只在一棵树上回答?
依次把 C、F 从图上擦掉,并从 A 出发标记还可到达的点。记录:
| 被删除的点 | A 一侧还看见谁 | 另一侧有哪些点 | 这说明什么 |
|---|---|---|---|
| C | |||
| F |
停顿 0:删 C 后,A、B、D、E、F、G 分成哪几堆?C 若是“某个团的成员”,它要属于几团?
把三角形 ABC 圈起来。删除其中任一顶点,剩下两点仍有边相连;所以它内部没有一个单点能把其余成员拆开。CDE 也一样。单条走廊 CF 虽然没有绕路,但在以边为单位的点双分解里,它也形成一个含 C、F 的块:它提醒我们这条连接只经由两端。
停顿 1:为什么“删 C 后 A 到 E 断了”不等于“ABC 这个三角形不结实”?请分别写出这两个问题删除后的剩余对象。
试过“给每个点双块涂一种颜色”后,C 会被迫涂三种颜色。不要复制 C;复制的是块这个关系。做法如下:
请为 ABC、CDE、CF、FG 分别画方点 Q1、Q2、Q3、Q4,再连到对应圆点。
停顿 2:在你新画出的图中,圆点 C 的度数是多少?从圆点 A 到圆点 G 的唯一路径经过哪些方点和圆点?
先只看连通原图。每加入一个新点双块,它若和已经出现的块共享两个原点,就会与旧块合并成更大的点双块,矛盾;因此它只会通过一个割点接到已有部分。每个新方点像给树挂上一颗新叶子,圆方图不会出现环。
这一步很重要:圆方树不是“把原图画得好看”,而是用一棵树保存原图的块—割点骨架。原图的一条边落在某个方点内部;跨块路径则只能在圆方树中穿过相应圆点。
刚才逐个擦点很直观,但若有 n 个点、m 条边,每删一个点都重做一次 DFS,会接近 O(n(n+m))。我们需要在一次 DFS 中记住:某个孩子子树有没有一条边能绕回父亲之前的祖先。
从 A DFS。每走一条树边,或看见一条通往祖先的返祖边,就把边编号压进栈。为什么不是压顶点?因为 C 需要出现在多个块里;一条边却恰好属于一个点双块,最后可以放心交出去。
令 dfn[u] 是 u 首次被看到的时间,low[u] 是从 u 的 DFS 子树出发,经零条或多条树边、至多一条返祖边,能碰到的最早祖先时间。
当树边 u-v 满足:
$$ low[v]\ge dfn[u], $$
v 子树再也不能绕到 u 的上方。栈顶直到边 u-v 为止的走廊恰好封成一个点双块;把它们弹出,新建一个方点。
停顿 3:在图中从 C 走到 F,再走到 G。G 返回 F 时为什么必然封出 FG?F 返回 C 时为什么又会封出 CF?
DFS(u, parent_edge):
dfn[u] = low[u] = next_time()
for edge e=(u,v):
if e is parent_edge: continue
if v is unvisited:
push e onto edge_stack
DFS(v, e)
low[u] = min(low[u], low[v])
if low[v] >= dfn[u]:
pop edges through e as one block
create one square node for their endpoints
else if dfn[v] < dfn[u]:
push e onto edge_stack
low[u] = min(low[u], dfn[v])
适用条件:这里是静态无向图。重边必须按边编号跳过父边,不能把“连到父亲”全跳过;否则另一条平行边提供的绕路会消失。
| 节点 | 表示 | 它连向谁 | 读路径时意味着什么 |
|---|---|---|---|
| 圆点 | 原图一个顶点 | 包含它的方点 | 可能是割点,也可能只是块内成员 |
| 方点 | 一个点双连通块 | 该块的原顶点 | 在块内部可以绕开单个成员 |
原图连通时,圆方树是一棵树;原图不连通时得到森林。两原点间在原图跨越的点双块顺序,就是圆方树上两圆点之间的路径。若题目询问“从 u 到 v 必经多少个割点”,常可在这条树路径上统计度数大于 1 的圆点(但要按题目是否计端点调整)。
正确性的账本有两层:边栈弹出的边在边界出现前不能和更早边组成同一块;边界出现时它们又通过 DFS 路径和返祖边连成最大块。每条边压、弹各一次,Tarjan 与建树总计 O(n+m),树有至多 n 加上块数个节点。
按邻接顺序 A→B→C→D→E→F→G DFS。先在纸上补全表;返祖边 E-C 和 C-A 是最关键的两笔。
| 返回的孩子边 | low[孩子] | 与父亲 dfn 比较 | 弹出的边 | 新方点 |
|---|---|---|---|---|
| D-E | ||||
| C-D | ||||
| F-G | ||||
| C-F | ||||
| A-B |
接着在你画出的圆方树上圈出 A 到 E、D 到 G 两条路径。问自己:若原图某条块内边替换成另一条块内边,圆方树路径会变吗?不会;圆方树刻意忘掉了块内部细节,只保留“从哪个门进、从哪个门出”的结构。
运行 从共享房间到圆方树.py。它会先请你预测删 C 的结果,再打印边栈的压入、弹出和生成的方点。可输入:
先预测 Q1 到 Q4 中哪一个方点会连接 C,再运行;程序只是验算员,不会替你补纸上的箭头。