Boyer‑Myrvold 平面性检测与平面嵌入算法

· · 算法·理论

完全使用 gpt-oss-120b 生成。

Boyer‑Myrvold 平面性检测与平面嵌入算法

作者:John Boyer、Robert Myrvold(2004)
核心思想:在一次深度优先搜索(DFS)过程中维护 嵌入信息,并在发现冲突时即时“回滚”或“恢复”。该实现比 1974 年的 Hopcroft‑Tarjan 版更简洁、常数因子更小,已成为多数平面性检测库(如 Boost、NetworkX、Planarity)默认的内部实现。

下面从 概念数据结构主要步骤细节实现时间/空间复杂度实用要点 逐层展开。

1. 基本概念

术语 说明
平面图 能够在平面上画而不产生边交叉的无向图。
平面嵌入 给出每条边的几何位置(通常是直线段)以及每个顶点的邻接顺序(环序)。
DFS 树 对图进行深度优先遍历时得到的树结构(父子关系)。
lowpoint(v) 在 DFS 子树中能够回到祖先的最小深度编号(类似于割点/桥的 low 值)。
back edge 连接当前顶点与已经在 DFS 栈中的祖先的非树边。
forward edge 只在有向图中出现,DFS 中不使用。
embedding stack 用来记录 “嵌入操作”(把一条 back edge 插入当前面)的栈,以便在冲突时回滚。
separation pair 两条相邻的 back edge 产生的冲突点,算法会把它们合并为一个 “分离对”(separation pair)并递归处理子图。

目标:在 O(|V|) 时间内判断图是否平面,并在平面情况下输出 每个顶点的环序(即组合嵌入)。几何坐标(直线嵌入)可以随后通过 Tutte、FPP 等方法从环序转化。

2. 关键数据结构

结构 作用 细节
DFS number (dfn) 为每个顶点分配递增的访问序号(1…n)。 int 保存。
parent[v] DFS 树中 v 的父节点。 便于回溯。
low[v] v 能回到的最小 dfn(包括自身的 back edge)。 low[v] = min(dfn[v], min{ low[child] | child is tree edge }, min{ dfn[w] | (v,w) is back edge })
stack S 嵌入栈:保存 “插入操作”(把一条 back edge 加入当前面)。每条操作记录:<br>edge (u,v)face idposition in adjacency list 当检测到冲突时,弹出栈中对应的操作以回滚。
adjacency order (circular list) 对每个顶点维护 环序(邻接列表的循环顺序)。在 DFS 过程中动态插入/删除边时更新此结构。实现上常用 双向链表(或 std::list)并记录 迭代器指向每条已处理的边。
separation‑pair stack 用来保存 分离对(两个相邻 back edge 产生的冲突)以及对应的 子图根,递归处理时使用。
visited flag 标记顶点是否已进入 DFS。

实现技巧

  • 使用 邻接表 + 边对象,每条边保存两个方向的 EdgeInfo(包括指向对方的 rev、在对应顶点的迭代器位置)。
  • EdgeInfo 里额外保存 bool processed 标记,防止同一 back edge 被多次插入。
  • adjacency ordercircular doubly linked list,因为插入时需要在 O(1) 时间把新边放在两已有邻居之间。

3. 算法的主要步骤

3.1 初始化

  1. dfn = 0,所有 low = ∞parent = -1
  2. 对每个未访问的顶点 v 调用 DFS(v)(处理非连通图的情况)。

3.2 深度优先搜索(DFS) + 嵌入维护

DFS(v):
    dfn[v] = ++time
    low[v] = dfn[v]

    for each edge e = (v, w) in adjacency list:
        if e is already processed: continue

        if w not visited:
            parent[w] = v
            mark e as tree edge
            // 1. 递归进入子树
            DFS(w)

            // 2. 合并子树的嵌入信息
            low[v] = min(low[v], low[w])

            // 3. 处理子树返回的 back edges
            //    (在子树根 w 处已经把所有 back edges插入了环序)
            //    此时只需要把 w 与 v 之间的树边加入 v 的环序
            InsertTreeEdgeIntoAdjOrder(v, w)

        else if dfn[w] < dfn[v] and w != parent[v]:
            // 发现 back edge (v,w) 向上指向祖先
            low[v] = min(low[v], dfn[w])
            mark e as back edge

            // 4. 把 back edge 插入当前面
            InsertBackEdgeIntoAdjOrder(v, w)

核心子过程

  1. InsertTreeEdgeIntoAdjOrder(v, w)

    • v 的环序中把 w 插入 父子位置(即在 v 的当前面中把 w 放在 v 与其父的相邻位置之间)。
    • 同时在 w 的环序中把 v 插入对应位置(形成互补的环)。
  2. InsertBackEdgeIntoAdjOrder(v, w)

    • 需要 确定 back edge 所在的 (即当前 DFS 栈中最近的未闭合面)。
    • 使用 嵌入栈 S:每当进入一个 back edge时,把 (v,w,faceID) 入栈;当后续的树边或 back edge导致该面闭合时弹出。
    • 插入时把 w 放在 v环序中与 v 的父/子邻居之间的 正确位置(由栈顶的 faceID 决定)。
  3. 冲突检测 & 分离对处理

    • 当尝试把 back edge 插入时,如果在 v 的环序中找不到合法的插入位置(即两相邻的已插入边已经占用了所有可用槽),则产生 冲突
    • 冲突必然表现为 两个相邻的 back edge 交叉形成 separation pair
    • 处理方式:把冲突的子图压入分离对栈,递归地在该子图内部重新执行 DFS(相当于把原图拆成若干子块),每块独立检测平面性。
    • 关键是 “回滚”:弹出嵌入栈 S 中对应的插入操作,恢复到冲突前的环序状态,然后在子图内部重新构造。

3.3 完成 DFS 后的收尾

4. 细节实现要点

4.1 环序的维护(双向循环链表)

struct AdjNode {
    int neighbor;               // 邻居编号
    list<AdjNode>::iterator it; // 在该顶点的环序链表中的位置
    Edge* rev;                  // 指向对方方向的 Edge
    bool processed;            // 是否已加入嵌入
};

vector<list<AdjNode>> adjOrder(n); // 每个顶点的环序

4.2 嵌入栈的结构

struct EmbedOp {
    int u, v;               // 边的两个端点
    list<AdjNode>::iterator uPos, vPos; // 插入时的迭代器(用于回滚)
    int faceID;            // 所属面标识
};

stack<EmbedOp> embedStack;

4.3 分离对的递归处理

struct SepPair {
    int a, b;          // 两条相邻 back edge 的端点
    vector<int> verts; // 受影响的顶点集合(子图)
};

stack<SepPair> sepStack;

4.4 复杂度控制

5. 伪代码完整示例(C++‑风格)

int time = 0;
vector<int> dfn(n, 0), low(n, 0), parent(n, -1);
vector<bool> visited(n, false);
vector<list<AdjNode>> adjOrder(n);
stack<EmbedOp> embedStack;
stack<SepPair> sepStack;

void DFS(int v) {
    visited[v] = true;
    dfn[v] = low[v] = ++time;

    for (Edge* e : edgesFrom(v)) {
        if (e->processed) continue;
        int w = e->to;

        if (!visited[w]) {                     // tree edge
            parent[w] = v;
            e->processed = e->rev->processed = true;
            DFS(w);

            low[v] = min(low[v], low[w]);

            // Insert tree edge into adjacency order
            auto posV = findInsertPos(v, w);   // O(1) via parent info
            auto itV = adjOrder[v].insert(posV, AdjNode{w, nullptr, e->rev, true});
            auto itW = adjOrder[w].insert(adjOrder[w].begin(), AdjNode{v, nullptr, e, true});
            // record for possible rollback
            embedStack.push({v,w,itV,itW,-1});
        }
        else if (dfn[w] < dfn[v] && w != parent[v]) { // back edge upward
            low[v] = min(low[v], dfn[w]);
            e->processed = e->rev->processed = true;

            // Try to insert back edge into current face
            if (!insertBackEdge(v, w)) {
                // Conflict → build separation pair
                SepPair sp = buildSeparationPair(v, w);
                sepStack.push(sp);
                // Re‑enter DFS on the subgraph defined by sp
                processSeparationPair(sp);
                // After subgraph is processed, continue
            }
        }
    }
}

/* insertBackEdge returns true if insertion succeeded,
   false → conflict (needs separation pair handling) */
bool insertBackEdge(int v, int w) {
    // Determine the face where (v,w) should be placed:
    // face = top of embedStack (or a global faceID = 0 for outer face)
    int face = (embedStack.empty() ? 0 : embedStack.top().faceID);

    // Find legal position in v's circular list:
    auto pos = findLegalInsertPos(v, w, face);
    if (pos == adjOrder[v].end()) return false; // no slot → conflict

    auto itV = adjOrder[v].insert(pos, AdjNode{w, nullptr, edgeRev(v,w), true});
    auto itW = adjOrder[w].insert(findLegalInsertPos(w, v, face), AdjNode{v, nullptr, edgeRev(w,v), true});

    embedStack.push({v,w,itV,itW,face});
    return true;
}

/* processSeparationPair recursively handles the subgraph */
void processSeparationPair(const SepPair& sp) {
    // mark vertices in sp.verts as unvisited for a fresh DFS
    for (int v : sp.verts) visited[v] = false;
    // run DFS only on this induced subgraph
    for (int v : sp.verts)
        if (!visited[v]) DFS(v);
    // after finishing, the subgraph is embedded; merge its order
    mergeSubEmbedding(sp);
}

说明

  • findInsertPos / findLegalInsertPos 通过父指针或已有 faceID 快速定位(常数时间)。
  • edgeRev(u,v) 返回指向相反方向的 Edge*,用于同步两端的 processed 标记。
  • buildSeparationPair 通过在当前 DFS 栈中寻找两条相邻的 back edge,收集它们围住的顶点集合(可用 BFS/DFS 标记)。
  • mergeSubEmbedding 把子图的 adjOrder 合并到全局结构,保持环序的一致性。

6. 正确性要点(为什么是线性)

  1. DFS 树的 lowpoint 规则保证每条 back edge只能指向祖先,避免出现向后跨越多层的情况。
  2. 嵌入栈记录所有插入操作,使得冲突时只需弹出最近的若干操作,不需要重新遍历已完成的子树
  3. 分离对递归把冲突局部化为子图,子图大小严格递减,且每条冲突只会产生一次递归。
  4. 每条边最多一次插入(无论是 tree 还是 back),且每次插入/删除都是 O(1)。
  5. DFS 本身线性,加上上述常数时间的嵌入维护,整体 O(n)。

7. 从组合嵌入到几何嵌入

完成 Boyer‑Myrvold 后,你会得到 每个顶点的邻接环序(即 adjOrder[v] 的循环顺序)。要把它转化为实际坐标,可任选以下方法:

方法 适用情形 关键步骤
Tutte 弹簧嵌入 任意平面图 把外部面固定在凸多边形,解拉普拉斯方程 L * X = 0(线性系统)。
de Fraysseix‑Pach‑Pollack (FPP) 需要整数网格、面积 ≤ O(n²) 先三角化 → 递归删除度 ≤5 的顶点 → 逐层插回,坐标在 O(n)×O(n) 网格上。
Schnyder Realizer 三角化平面图或先三角化的通用平面图 构造 3‑树划分 → 在 (n‑2)×(n‑2) 网格上给出整数坐标。

实现建议:如果你使用 Python + NetworkX,nx.check_planarity(G, counterexample=False) 已经返回 PlanarEmbedding(即环序),随后可以直接调用 nx.combinatorial_embedding_to_pos(embedding)(或自行实现 Tutte)得到几何坐标。

8. 实际实现参考

语言/库 说明
C++ Boost Graph Library (boost::graph::boyer_myrvold_planar_test) 完整实现,返回 planar_embedding.
Python NetworkX nx.check_planarity(内部调用 Boyer‑Myrvold),返回 PlanarEmbedding.
C libplanarity(作者为 Boyer‑Myrvold 的原始实现),提供 planarity_testplanarity_embed.
Java JGraphT PlanarityTestingAlgorithm(实现了 Boyer‑Myrvold)。

调试技巧

  • 在实现时加入 断言:每次 insertBackEdge 前检查 adjOrder[v] 中是否已经出现 w,防止重复插入。
  • 使用 可视化(如在每次插入后绘制当前环序)帮助定位冲突产生的具体位置。
  • 对于大规模图(>10⁶ 边),建议使用 稀疏矩阵 +并行化 的 lowpoint 计算(虽然理论上 O(n) 已足够快),但实际 I/O 成本往往是瓶颈。

9. 小结

目标 推荐实现
快速平面性检测 Boyer‑Myrvold(一次 DFS + 嵌入栈) → O(n) 时间、O(n) 空间。
获取组合嵌入(环序) PlanarEmbedding 结构:每顶点的邻接顺序。
后续几何嵌入 Tutte、FPP、Schnyder 等任选。
库/函数 C++ Boost boyer_myrvold_planar_test、Python NetworkX check_planarity、C libplanarity

只要在 DFS 过程中严格维护 嵌入栈环序链表,并在冲突时通过 分离对递归 处理子图,Boyer‑Myrvold 就能在 线性时间 完成平面性检测并输出完整的平面嵌入。祝你实现顺利!如果还有具体实现细节或代码调试上的疑问,欢迎继续交流。