Files
spqrtree/src/spqrtree/_triconnected.py
依瑪貓 dc307acac7 Initial commit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:52:03 +08:00

1496 lines
49 KiB
Python

# Pure Python SPQR-Tree implementation.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/2
# AI assistance: Claude Code (Anthropic)
# Copyright (c) 2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Triconnected components for the SPQR-Tree algorithm.
Implements decomposition of a biconnected multigraph into its
triconnected split components: BONDs (parallel-edge groups),
POLYGONs (simple cycles), and TRICONNECTED subgraphs.
The algorithm proceeds in three phases:
1. **Multi-edge phase**: Each group of parallel edges becomes a BOND
split component; they are replaced by a single virtual edge.
2. **PathSearch phase**: An iterative DFS using the Gutwenger-Mutzel
(2001) algorithm detects separation pairs and creates split
components using an edge stack (ESTACK) and triple stack (TSTACK).
3. **Merge phase**: Adjacent split components of the same type that
share a virtual edge are merged (Algorithm 2).
Public API::
from spqrtree._triconnected import (
ComponentType, TriconnectedComponent,
find_triconnected_components,
)
"""
import bisect
from collections.abc import Hashable
from dataclasses import dataclass
from enum import Enum
from spqrtree._graph import Edge, MultiGraph
from spqrtree._palm_tree import (
LOWPT_INF,
PalmTree,
build_palm_tree,
sort_adjacency_lists,
)
# Sentinel triple marking the end of a path segment on the TSTACK.
_EOS: tuple[int, int, int] = (-1, -1, -1)
class ComponentType(Enum):
"""Classification of a triconnected split component.
:cvar BOND: A bond - two vertices connected by multiple parallel
edges.
:cvar POLYGON: A simple cycle - every vertex has degree 2 in the
component.
:cvar TRICONNECTED: A 3-connected subgraph.
"""
BOND = "bond"
"""A bond component: two vertices connected by parallel edges."""
POLYGON = "polygon"
"""A polygon component: a simple cycle."""
TRICONNECTED = "triconnected"
"""A triconnected component: a 3-connected subgraph."""
@dataclass
class TriconnectedComponent:
"""A single triconnected split component.
:param type: Classification as BOND, POLYGON, or TRICONNECTED.
:param edges: All edges in this component (real and virtual).
Virtual edges are shared with exactly one other component.
"""
type: ComponentType
"""Classification of this component."""
edges: list[Edge]
"""All edges in this component (real and virtual)."""
def find_triconnected_components(
graph: MultiGraph,
) -> list[TriconnectedComponent]:
"""Find all triconnected split components of a biconnected multigraph.
The input must be a biconnected multigraph (possibly with parallel
edges). Returns a list of TriconnectedComponent objects, each
classified as BOND, POLYGON, or TRICONNECTED.
Each real (non-virtual) edge of the input appears in exactly one
component. Each virtual edge (added by the algorithm) appears in
exactly two components, representing the two sides of a separation
pair.
:param graph: A biconnected multigraph.
:return: A list of TriconnectedComponent objects.
"""
if graph.num_vertices() == 0 or graph.num_edges() == 0:
return []
# Work on a copy to avoid modifying the caller's graph.
g: MultiGraph = graph.copy()
# Accumulated split components (each is a list of edge IDs).
raw_comps: list[list[int]] = []
# Set of virtual edge IDs added during decomposition.
virtual_ids: set[int] = set()
# Phase 1: Split off multi-edges.
_phase_multiedge(g, raw_comps, virtual_ids)
# If only 2 vertices remain (or fewer), skip PathSearch.
if g.num_vertices() >= 2 and g.num_edges() >= 1:
# Phase 2: PathSearch.
_phase_pathsearch(g, raw_comps, virtual_ids)
# Phase 3: Classify and merge.
return _phase_classify_merge(raw_comps, virtual_ids, graph, g)
# ---------------------------------------------------------------------------
# Phase 1: Multi-edge splitting (Algorithm 1)
# ---------------------------------------------------------------------------
def _phase_multiedge(
g: MultiGraph,
raw_comps: list[list[int]],
virtual_ids: set[int],
) -> None:
"""Replace groups of parallel edges with virtual edges (Algorithm 1).
For each pair of vertices (u, v) with k >= 2 parallel edges,
creates a BOND split component {e_1, ..., e_k, e'} where e' is a
new virtual edge, then removes e_1, ..., e_k from g, leaving e'.
:param g: The working multigraph (modified in place).
:param raw_comps: List to append new split components to.
:param virtual_ids: Set to add new virtual edge IDs to.
:return: None
"""
# Collect all unordered vertex pairs.
seen_pairs: set[frozenset[Hashable]] = set()
pairs: list[tuple[Hashable, Hashable]] = []
for e in g.edges:
key: frozenset[Hashable] = frozenset((e.u, e.v))
if key not in seen_pairs:
seen_pairs.add(key)
pairs.append((e.u, e.v))
for u, v in pairs:
parallel: list[Edge] = g.edges_between(u, v)
if len(parallel) < 2:
continue
# Only create a virtual edge if there are other edges in g
# (i.e., the bond is embedded in a larger graph).
if g.num_edges() > len(parallel):
ve: Edge = g.add_edge(u, v, virtual=True)
virtual_ids.add(ve.id)
comp_eids: list[int] = [e.id for e in parallel] + [ve.id]
else:
# Entire graph is this bond: no virtual edge needed.
comp_eids = [e.id for e in parallel]
raw_comps.append(comp_eids)
# Remove original parallel edges.
for e in parallel:
g.remove_edge(e.id)
def _classify_start_edge(
eid: int,
w: Hashable,
v: Hashable,
v_num: int,
w_num: int,
tree_edges: set[int],
fronds: set[int],
parent_edge: dict[Hashable, int | None],
) -> tuple[bool, bool]:
"""Classify an edge for the start-set computation.
:param eid: Edge ID to classify.
:param w: The other endpoint.
:param v: The current vertex.
:param v_num: DFS number of v.
:param w_num: DFS number of w.
:param tree_edges: Set of tree edge IDs.
:param fronds: Set of frond edge IDs.
:param parent_edge: Parent edge map.
:return: (is_tree_arc, is_frond) tuple.
"""
is_tree_arc: bool = (eid in tree_edges
and parent_edge.get(w) == eid)
is_frond: bool = (eid in fronds
and w_num < v_num
and eid != parent_edge.get(v))
return is_tree_arc, is_frond
def _compute_start_set(g: MultiGraph, pt: PalmTree) -> set[int]:
"""Compute start edges using the path-finder traversal.
An edge starts a new path if ``new_path`` is True when the edge is
first traversed in DFS order. ``new_path`` starts True, is set to
False after the first outgoing arc (tree edge or frond) is marked,
and is set back to True after any frond is processed. Backtracking
never changes ``new_path``.
This corresponds to the ``starts_path`` computation in the
Gutwenger-Mutzel (2001) path_finder subroutine and determines which
tree-edge arcs push a new EOS sentinel onto TSTACK during PathSearch.
:param g: The multigraph with phi-sorted adjacency lists.
:param pt: The palm tree computed for g.
:return: Set of edge IDs that start a new path segment.
"""
start_set: set[int] = set()
if not g.vertices:
return start_set
dfs_num: dict[Hashable, int] = pt.dfs_num
tree_edges: set[int] = pt.tree_edges
fronds: set[int] = pt.fronds
parent_edge: dict[Hashable, int | None] = pt.parent_edge
root: Hashable = min(
g.vertices, key=lambda v: dfs_num[v])
adj_lists: dict[Hashable, list[int]] = {
v: g.adj_edge_ids(v) for v in g.vertices}
new_path: bool = True
# Stack entries: (vertex, adj_index)
stack: list[tuple[Hashable, int]] = [(root, 0)]
while stack:
v, idx = stack[-1]
adj: list[int] = adj_lists[v]
if idx >= len(adj):
stack.pop()
continue
eid: int = adj[idx]
stack[-1] = (v, idx + 1)
if not g.has_edge(eid):
continue
e: Edge | None = g.get_edge(eid)
assert e is not None
w: Hashable = e.other(v)
v_num: int = dfs_num[v]
w_num: int = dfs_num.get(w, 0)
is_tree_arc, is_frond = _classify_start_edge(
eid, w, v, v_num, w_num,
tree_edges, fronds, parent_edge)
if is_tree_arc:
# Tree edge v -> w (w is a child of v).
if new_path:
start_set.add(eid)
new_path = False
stack.append((w, 0))
elif is_frond:
# Frond v -> w (w is a proper ancestor of v).
if new_path:
start_set.add(eid)
new_path = True
# Parent edge or reverse-direction edge: skip.
return start_set
def _renumber_palm_tree(pt: PalmTree) -> None:
"""Renumber a palm tree using the Gutwenger-Mutzel scheme.
Assigns ``newnum[v] = counter - nd[v] + 1`` where *counter*
starts at *n* and decrements on backtrack. Within a vertex's
children, the first child (in phi-sorted order) receives the
**highest** DFS numbers and the last child the **lowest**. This
matches the numbering in Gutwenger-Mutzel (2001) §3.3 and is
required for correct TSTACK triple ranges in PathSearch.
Updates ``dfs_num``, ``lowpt1``, and ``lowpt2`` in place.
:param pt: The palm tree to renumber.
:return: None
"""
n: int = len(pt.dfs_num)
old_to_new: dict[int, int] = {}
counter: int = n
# Iterative DFS following children order.
root: Hashable = next(
v for v, p in pt.parent.items() if p is None)
stack: list[tuple[Hashable, bool]] = [(root, False)]
visited: set[Hashable] = set()
while stack:
v, returning = stack.pop()
if returning:
counter -= 1
continue
if v in visited:
continue
visited.add(v)
old_num: int = pt.dfs_num[v]
new_num: int = counter - pt.nd[v] + 1
old_to_new[old_num] = new_num
# Push backtrack marker.
stack.append((v, True))
# Push children in reverse order so first child is
# processed first (popped last from reversed push).
for child in reversed(pt.children.get(v, [])):
stack.append((child, False))
# Update dfs_num.
for v in pt.dfs_num:
pt.dfs_num[v] = old_to_new[pt.dfs_num[v]]
# Update lowpt1 and lowpt2.
for v in pt.lowpt1:
pt.lowpt1[v] = old_to_new[pt.lowpt1[v]]
for v in pt.lowpt2:
old_val: int = pt.lowpt2[v]
if old_val == LOWPT_INF:
continue
pt.lowpt2[v] = old_to_new.get(old_val, old_val)
# ---------------------------------------------------------------------------
# Phase 2: PathSearch (Algorithms 3 - 6)
# ---------------------------------------------------------------------------
class _PathSearcher:
"""PathSearch algorithm state and methods (Algorithms 3-6).
Encapsulates the mutable state and sub-algorithms for the
Gutwenger-Mutzel (2001) PathSearch. Splitting the logic into
methods keeps cognitive complexity manageable.
"""
def __init__(
self,
g: MultiGraph,
raw_comps: list[list[int]],
virtual_ids: set[int],
) -> None:
"""Initialize the PathSearch state.
Builds the palm tree, sorts adjacency lists, and sets up
all mutable data structures needed by the algorithm.
:param g: The working multigraph.
:param raw_comps: Accumulator for split components.
:param virtual_ids: Accumulator for virtual edge IDs.
:return: None
"""
self.g: MultiGraph = g
"""The working multigraph."""
self.raw_comps: list[list[int]] = raw_comps
"""Accumulated raw split components."""
self.virtual_ids: set[int] = virtual_ids
"""Set of virtual edge IDs."""
# Build palm tree and sort adjacency lists.
start: Hashable = next(iter(g.vertices))
pt: PalmTree = build_palm_tree(g, start)
sort_adjacency_lists(g, pt)
pt = build_palm_tree(g, start)
_renumber_palm_tree(pt)
self.dfs_num: dict[Hashable, int] = pt.dfs_num
"""DFS discovery numbers."""
self.parent: dict[Hashable, Hashable | None] = pt.parent
"""DFS tree parent for each vertex."""
self.lowpt1: dict[Hashable, int] = pt.lowpt1
"""Lowpt1 values."""
self.lowpt2: dict[Hashable, int] = pt.lowpt2
"""Lowpt2 values."""
self.nd: dict[Hashable, int] = pt.nd
"""Subtree sizes."""
self.inv_dfs: dict[int, Hashable] = {
n: v for v, n in pt.dfs_num.items()}
"""Inverse DFS map: DFS number to vertex."""
self.cur_parent_edge: dict[Hashable, int | None] = dict(
pt.parent_edge)
"""Current parent edge for each vertex."""
self.cur_tree: set[int] = set(pt.tree_edges)
"""Current set of tree edge IDs."""
self.cur_deg: dict[Hashable, int] = {
v: g.degree(v) for v in g.vertices}
"""Current degree of each vertex."""
self.cur_children: dict[Hashable, list[Hashable]] = {
v: list(pt.children[v])
for v in g.vertices}
"""Current DFS children for each vertex."""
self.fronds: set[int] = pt.fronds
"""Set of frond edge IDs from palm tree."""
# Build frond sources for _high().
self.frond_srcs: dict[int, list[int]] = {
pt.dfs_num[v]: [] for v in g.vertices}
"""Frond source DFS numbers by target."""
self.in_high: dict[int, tuple[int, int]] = {}
"""Maps edge ID to (target_dfs, source_dfs) for
highpt tracking."""
for eid in pt.fronds:
e: Edge | None = g.get_edge(eid)
if e is None:
continue
if pt.dfs_num[e.u] > pt.dfs_num[e.v]:
s: int = pt.dfs_num[e.u]
d: int = pt.dfs_num[e.v]
else:
s = pt.dfs_num[e.v]
d = pt.dfs_num[e.u]
self.frond_srcs[d].append(s)
self.in_high[eid] = (d, s)
for lst in self.frond_srcs.values():
lst.sort()
self.start_set: set[int] = _compute_start_set(g, pt)
"""Edge IDs that start a new path segment."""
self.estack: list[int] = []
"""Edge stack (ESTACK)."""
self.tstack: list[tuple[int, int, int]] = []
"""Triple stack (TSTACK)."""
self.adj_cache: dict[Hashable, list[int]] = {
v: list(g.adj_edge_ids(v))
for v in g.vertices}
"""Cached adjacency lists."""
self.consumed: set[int] = set()
"""Edge IDs consumed by split components."""
self.adj_len: dict[Hashable, int] = {}
"""Original adjacency list length per vertex for DFS
iteration bounds."""
self.y_accum: dict[Hashable, int] = {}
"""Accumulated TSTACK h-value per vertex, persisting
across children (matching SageMath's y_dict)."""
self.call_stack: list[tuple] = []
"""DFS call stack."""
def run(self) -> None:
"""Execute the main PathSearch DFS loop.
Processes visit and post-return frames iteratively.
Any remaining edges on ESTACK form a final component.
:return: None
"""
root: Hashable = self.inv_dfs[1]
self.adj_len[root] = len(
self.adj_cache.get(root, []))
self.y_accum[root] = 0
self.call_stack = [('visit', root, 0)]
while self.call_stack:
frame: tuple = self.call_stack[-1]
if frame[0] == 'post':
self.call_stack.pop()
self._process_post_frame(
frame[1], frame[2], frame[3], frame[4])
continue
_, v, idx = frame
adj: list[int] = self.adj_cache.get(v, [])
bound: int = self.adj_len.get(v, len(adj))
if idx >= bound:
self.call_stack.pop()
continue
eid: int = adj[idx]
self.call_stack[-1] = ('visit', v, idx + 1)
if not self.g.has_edge(eid):
continue
e: Edge | None = self.g.get_edge(eid)
assert e is not None
w: Hashable = e.other(v)
v_num: int = self.dfs_num[v]
w_num: int = self.dfs_num.get(w, -1)
if w_num < 0:
continue
is_start: bool = eid in self.start_set
is_tree_arc: bool = (
eid in self.cur_tree
and self.cur_parent_edge.get(w) == eid)
is_out_frond: bool = (
eid in self.fronds and w_num < v_num
and eid != self.cur_parent_edge.get(v))
if is_tree_arc:
self._process_tree_edge(
v, eid, w, v_num, w_num, is_start)
elif is_out_frond:
self._process_frond(
v, eid, w, v_num, w_num, is_start)
if self.estack:
self.raw_comps.append(list(self.estack))
def _high(self, w_num: int) -> int:
"""Return the largest frond-source DFS number.
:param w_num: DFS number of vertex w.
:return: Largest frond-source DFS number, or 0.
"""
fl: list[int] = self.frond_srcs.get(w_num, [])
return fl[-1] if fl else 0
def _del_high(self, eid: int) -> None:
"""Remove the highpt entry for edge *eid*.
If *eid* has a recorded ``in_high`` entry, removes
the corresponding source from ``frond_srcs``.
:param eid: The edge ID whose highpt entry to remove.
:return: None
"""
entry: tuple[int, int] | None = self.in_high.pop(
eid, None)
if entry is None:
return
target, source = entry
fl: list[int] = self.frond_srcs.get(target, [])
idx: int = bisect.bisect_left(fl, source)
if idx < len(fl) and fl[idx] == source:
fl.pop(idx)
def _first_child_num(self, v: Hashable) -> int:
"""Return DFS number of v's first child, or 0.
:param v: A vertex.
:return: DFS number of the first child, or 0.
"""
ch: list[Hashable] = self.cur_children.get(v, [])
return self.dfs_num[ch[0]] if ch else 0
def _remaining_deg(self, w: Hashable) -> int:
"""Return the number of non-consumed edges of *w*.
Counts edges in the adjacency cache that have not
been consumed by split components and still exist
in the graph.
:param w: A vertex.
:return: Count of remaining incident edges.
"""
count: int = 0
for eid in self.adj_cache.get(w, []):
if eid in self.consumed:
continue
if self.g.get_edge(eid) is None:
continue
count += 1
return count
def _temp_target_num(self, w: Hashable) -> int:
"""Return DFS number of the first remaining outgoing
edge target from *w*.
Scans the adjacency cache for the first edge that
has not been consumed and is not the parent edge,
returning the target DFS number for outgoing tree
arcs (to children) or outgoing fronds (to ancestors).
:param w: A vertex.
:return: DFS number of the target, or 0.
"""
w_num: int = self.dfs_num[w]
peid: int | None = self.cur_parent_edge.get(w)
for eid in self.adj_cache.get(w, []):
if eid in self.consumed or eid == peid:
continue
ed: Edge | None = self.g.get_edge(eid)
if ed is None:
continue
other: Hashable = ed.other(w)
o_num: int = self.dfs_num.get(other, -1)
if o_num < 0:
continue
is_tree: bool = eid in self.cur_tree
if is_tree and o_num > w_num:
return o_num
if not is_tree and o_num < w_num:
return o_num
return 0
def _add_virt(self, u: Hashable, v: Hashable) -> Edge:
"""Add a new virtual edge between u and v.
Records the virtual edge ID. Does NOT modify
cur_deg.
:param u: One endpoint.
:param v: Other endpoint.
:return: The new virtual Edge.
"""
e: Edge = self.g.add_edge(u, v, virtual=True)
self.virtual_ids.add(e.id)
return e
def _delete_tstack_above(
self, threshold: int,
) -> list[tuple[int, int, int]]:
"""Pop TSTACK triples with *a* above *threshold*.
Pops triples from TSTACK top while they are not EOS
and their second element (a) exceeds *threshold*.
:param threshold: The low-point threshold value.
:return: List of deleted triples.
"""
deleted: list[tuple[int, int, int]] = []
while (
self.tstack
and self.tstack[-1] != _EOS
and self.tstack[-1][1] > threshold
):
deleted.append(self.tstack.pop())
return deleted
def _process_post_frame(
self,
v: Hashable,
eid: int,
w_orig: Hashable,
is_start: bool,
) -> None:
"""Handle a post-return frame after recursing.
Pushes the tree edge onto ESTACK, runs type-2 and
type-1 checks, cleans up EOS triples, and pops
stale triples.
:param v: The parent vertex.
:param eid: The tree edge ID.
:param w_orig: The original child vertex.
:param is_start: Whether this edge starts a path.
:return: None
"""
ed: Edge | None = self.g.get_edge(eid)
if ed is not None:
w: Hashable = ed.other(v)
else:
w = w_orig
peid_w: int | None = self.cur_parent_edge.get(w)
if peid_w is not None:
self.estack.append(peid_w)
w = self._check_type2(v, w)
self._check_type1(v, w)
if is_start:
while (self.tstack
and self.tstack[-1] != _EOS):
self.tstack.pop()
if (self.tstack
and self.tstack[-1] == _EOS):
self.tstack.pop()
v_num: int = self.dfs_num[v]
while (
self.tstack
and self.tstack[-1] != _EOS
and self.tstack[-1][2] != v_num
and self._high(v_num)
> self.tstack[-1][0]
):
self.tstack.pop()
def _process_tree_edge(
self,
v: Hashable,
eid: int,
w: Hashable,
v_num: int,
w_num: int,
is_start: bool,
) -> None:
"""Handle a tree edge v -> w in the visit frame.
Updates TSTACK with a new triple if this is a start
edge, then pushes post-return and visit frames.
:param v: The current vertex.
:param eid: The tree edge ID.
:param w: The child vertex.
:param v_num: DFS number of v.
:param w_num: DFS number of w.
:param is_start: Whether this edge starts a path.
:return: None
"""
if is_start:
lp1w: int = self.lowpt1[w]
y_acc: int = self.y_accum.get(v, 0)
deleted: list[tuple[int, int, int]] = \
self._delete_tstack_above(lp1w)
if not deleted:
self.tstack.append(
(w_num + self.nd[w] - 1, lp1w, v_num))
else:
y: int = y_acc
for t in deleted:
y = max(y, t[0])
last_b: int = deleted[-1][2]
self.tstack.append((y, lp1w, last_b))
self.y_accum[v] = y
self.tstack.append(_EOS)
self.call_stack.append(('post', v, eid, w, is_start))
self.adj_cache[w] = list(self.g.adj_edge_ids(w))
self.adj_len[w] = len(self.adj_cache[w])
self.y_accum[w] = 0
self.call_stack.append(('visit', w, 0))
def _process_frond(
self,
v: Hashable,
eid: int,
w: Hashable,
v_num: int,
w_num: int,
is_start: bool,
) -> None:
"""Handle a frond v -> w in the visit frame.
Updates TSTACK if this is a start edge. If the frond
goes to the parent, creates a 2-component and replaces
the parent edge with a virtual edge.
:param v: The current vertex.
:param eid: The frond edge ID.
:param w: The ancestor vertex.
:param v_num: DFS number of v.
:param w_num: DFS number of w.
:param is_start: Whether this edge starts a path.
:return: None
"""
if is_start:
y_acc: int = self.y_accum.get(v, 0)
deleted: list[tuple[int, int, int]] = \
self._delete_tstack_above(w_num)
if not deleted:
self.tstack.append((v_num, w_num, v_num))
else:
y: int = y_acc
for t in deleted:
y = max(y, t[0])
last_b: int = deleted[-1][2]
self.tstack.append((y, w_num, last_b))
p_v: Hashable | None = self.parent.get(v)
if p_v is not None and w == p_v:
peid: int | None = self.cur_parent_edge.get(v)
if peid is not None and self.g.has_edge(peid):
self.raw_comps.append([eid, peid])
self.consumed.add(eid)
self.consumed.add(peid)
ve: Edge = self._add_virt(v, w)
self.cur_tree.add(ve.id)
self.cur_parent_edge[v] = ve.id
self.adj_cache[v] = list(
self.g.adj_edge_ids(v))
self.adj_cache[w] = list(
self.g.adj_edge_ids(w))
else:
self.estack.append(eid)
else:
self.estack.append(eid)
def _eval_type2_conds(
self, v_num: int, w: Hashable,
) -> tuple[bool, bool]:
"""Evaluate the two type-2 loop conditions.
Separates the boolean ``and``/``or`` sequences
from ``_check_type2`` to keep cognitive complexity
within limits.
:param v_num: DFS number of the current vertex.
:param w: The child vertex.
:return: (top_cond, deg2_cond) tuple.
"""
top_cond: bool = (
self.tstack
and self.tstack[-1] != _EOS
and self.tstack[-1][1] == v_num)
deg2_cond: bool = (
self.cur_deg.get(w, 0) == 2
and self._temp_target_num(w)
> self.dfs_num[w])
return top_cond, deg2_cond
def _type2_try_parent_pop(
self, v: Hashable, top_cond: bool,
) -> bool:
"""Try to pop a TSTACK triple whose parent is v.
If the top-of-stack condition holds and the parent of
b is v, pops the triple and returns True. Otherwise
returns False without modifying state.
:param v: The current vertex.
:param top_cond: Whether the TSTACK top condition holds.
:return: True if a triple was popped, False otherwise.
"""
if not top_cond:
return False
b: int = self.tstack[-1][2]
b_v: Hashable = self.inv_dfs[b]
if self.parent.get(b_v) != v:
return False
self.tstack.pop()
return True
def _type2_apply(
self, v: Hashable, w: Hashable,
deg2_cond: bool,
) -> Hashable | None:
"""Apply a type-2 split (deg2 or top variant).
Delegates to ``_check_type2_deg2`` when *deg2_cond*
is true, otherwise to ``_check_type2_top``.
:param v: The current vertex.
:param w: The current child vertex.
:param deg2_cond: Whether the degree-2 condition holds.
:return: New w vertex, or None.
"""
if deg2_cond:
return self._check_type2_deg2(v, w)
return self._check_type2_top(v)
def _check_type2(
self, v: Hashable, w: Hashable,
) -> Hashable:
"""Check for type-2 separation pairs (Algorithm 5).
Implements Algorithm 5 from Gutwenger-Mutzel (2001).
May update w if the algorithm creates new virtual edges.
Priority follows SageMath: (1) if top_cond and parent
of b_v is v, pop TSTACK and continue; (2) if deg2_cond,
do deg2 split; (3) otherwise do top split.
:param v: The current vertex label.
:param w: The child vertex (just processed).
:return: Updated w (may change if edges merge).
"""
v_num: int = self.dfs_num[v]
if v_num == 1:
return w
while True:
top_cond, deg2_cond = self._eval_type2_conds(
v_num, w)
if not top_cond and not deg2_cond:
break
if self._type2_try_parent_pop(v, top_cond):
continue
new_w: Hashable | None = self._type2_apply(
v, w, deg2_cond)
if new_w is not None:
w = new_w
elif deg2_cond:
break
return w
def _check_type2_top(
self, v: Hashable,
) -> Hashable | None:
"""Handle TSTACK-based type-2 separation pair.
Pops the top triple and splits edges in the range
[a, h]. The parent-check pop is handled by the
caller. Returns the new w vertex, or None to signal
that the caller should continue (no-op pop).
:param v: The current vertex.
:return: New w vertex, or None for continue.
"""
h, a, b = self.tstack[-1]
self.tstack.pop()
a_v: Hashable = self.inv_dfs[a]
b_v = self.inv_dfs[b]
comp_eids, e_ab = self._pop_range_edges(a, b, h)
if not comp_eids:
if e_ab is not None:
self.estack.append(e_ab)
return None
if e_ab is not None:
e_ab_ed: Edge | None = self.g.get_edge(e_ab)
self._del_high(e_ab)
self.consumed.add(e_ab)
if e_ab_ed is not None:
self.cur_deg[e_ab_ed.u] -= 1
self.cur_deg[e_ab_ed.v] -= 1
ve: Edge = self._add_virt(a_v, b_v)
comp_eids.append(ve.id)
self.raw_comps.append(comp_eids)
if e_ab is not None:
ve2: Edge = self._add_virt(a_v, b_v)
self.raw_comps.append([e_ab, ve.id, ve2.id])
ve = ve2
self.estack.append(ve.id)
self.adj_cache.setdefault(
a_v, []).append(ve.id)
self.cur_deg[a_v] += 1
self.cur_deg[b_v] += 1
self.cur_tree.add(ve.id)
self.parent[b_v] = v
self.cur_parent_edge[b_v] = ve.id
return b_v
def _pop_range_edges(
self, a: int, b: int, h: int,
) -> tuple[list[int], int | None]:
"""Pop ESTACK edges within DFS range [a, h].
Pops edges where both endpoints have DFS numbers in
[a, h]. The edge connecting vertices a and b (if
found) is separated out as *e_ab* and not consumed.
:param a: Lower bound of DFS range.
:param b: DFS number of the second separation vertex.
:param h: Upper bound of DFS range.
:return: (comp_eids, e_ab) — component edge IDs and
the optional {a, b} edge ID.
"""
comp_eids: list[int] = []
e_ab: int | None = None
while self.estack:
eid: int = self.estack[-1]
ed: Edge | None = self.g.get_edge(eid)
if ed is None:
self.estack.pop()
continue
eu: int = self.dfs_num.get(ed.u, -1)
ev_: int = self.dfs_num.get(ed.v, -1)
if not (a <= eu <= h and a <= ev_ <= h):
break
self.estack.pop()
if {eu, ev_} == {a, b}:
e_ab = eid
else:
self._del_high(eid)
self.consumed.add(eid)
self.cur_deg[ed.u] -= 1
self.cur_deg[ed.v] -= 1
comp_eids.append(eid)
return comp_eids, e_ab
def _check_type2_deg2(
self, v: Hashable, w: Hashable,
) -> Hashable | None:
"""Handle degree-2 based type-2 separation pair.
Pops two edges from ESTACK and creates a split
component. Returns the new w vertex, or None to
signal that the caller should break.
:param v: The current vertex.
:param w: The current child vertex.
:return: New w vertex, or None for break.
"""
if len(self.estack) < 2:
return None
e1id: int = self.estack.pop()
e2id: int = self.estack.pop()
ed1: Edge | None = self.g.get_edge(e1id)
ed2: Edge | None = self.g.get_edge(e2id)
if ed1 is None or ed2 is None:
if ed2 is not None:
self.estack.append(e2id)
if ed1 is not None:
self.estack.append(e1id)
return None
verts: set[Hashable] = (
{ed1.u, ed1.v, ed2.u, ed2.v} - {v, w})
if not verts:
self.estack.append(e2id)
self.estack.append(e1id)
return None
b_v: Hashable = min(
verts, key=lambda x: self.dfs_num.get(x, 0))
self.consumed.add(e1id)
self.consumed.add(e2id)
ve: Edge = self._add_virt(v, b_v)
self.cur_deg[v] -= 1
self.cur_deg[b_v] -= 1
comp_eids: list[int] = [e1id, e2id, ve.id]
self.raw_comps.append(comp_eids)
e_ab: int | None = None
if self.estack:
top_e: Edge | None = self.g.get_edge(
self.estack[-1])
if (top_e is not None
and {top_e.u, top_e.v} == {v, b_v}):
e_ab = self.estack.pop()
self._del_high(e_ab)
self.consumed.add(e_ab)
if e_ab is not None:
ve2: Edge = self._add_virt(v, b_v)
self.raw_comps.append([e_ab, ve.id, ve2.id])
self.cur_deg[v] -= 1
self.cur_deg[b_v] -= 1
ve = ve2
self.estack.append(ve.id)
self.adj_cache.setdefault(
v, []).append(ve.id)
self.cur_deg[v] += 1
self.cur_deg[b_v] += 1
self.cur_tree.add(ve.id)
self.parent[b_v] = v
self.cur_parent_edge[b_v] = ve.id
return b_v
def _pop_subtree_edges(
self, w_lo: int, w_hi: int,
) -> list[int]:
"""Pop ESTACK edges within a subtree DFS range.
Pops edges whose at least one endpoint has a DFS
number in [w_lo, w_hi]. Decrements degrees for
consumed edges.
:param w_lo: Lower bound of subtree DFS range.
:param w_hi: Upper bound of subtree DFS range.
:return: List of popped edge IDs.
"""
comp_eids: list[int] = []
while self.estack:
eid: int = self.estack[-1]
ed: Edge | None = self.g.get_edge(eid)
if ed is None:
self.estack.pop()
continue
eu: int = self.dfs_num.get(ed.u, -1)
ev_: int = self.dfs_num.get(ed.v, -1)
in_range: bool = ((w_lo <= eu <= w_hi)
or (w_lo <= ev_ <= w_hi))
if not in_range:
break
self.estack.pop()
self._del_high(eid)
self.consumed.add(eid)
comp_eids.append(eid)
self.cur_deg[ed.u] -= 1
self.cur_deg[ed.v] -= 1
return comp_eids
def _type1_try_combine(
self, v: Hashable, lp1w_v: Hashable,
ve: Edge,
) -> Edge:
"""Try to combine ESTACK top into a bond.
If the ESTACK top edge connects {v, lp1w_v}, pops it
and creates a bond component. Returns the (possibly
updated) virtual edge.
:param v: The current vertex.
:param lp1w_v: The lowpt1(w) vertex.
:param ve: The current virtual edge.
:return: Updated virtual edge (may be a new one).
"""
if not self.estack:
return ve
top_e: Edge | None = self.g.get_edge(
self.estack[-1])
if (top_e is None
or {top_e.u, top_e.v} != {v, lp1w_v}):
return ve
e_top: int = self.estack.pop()
self.consumed.add(e_top)
ve2: Edge = self._add_virt(v, lp1w_v)
self.raw_comps.append(
[e_top, ve.id, ve2.id])
self.cur_deg[v] -= 1
self.cur_deg[lp1w_v] -= 1
if e_top in self.in_high:
self.in_high[ve2.id] = \
self.in_high.pop(e_top)
return ve2
def _type1_parent_bond(
self, v: Hashable, lp1w_v: Hashable,
ve: Edge,
) -> None:
"""Handle the lp1w == pv_num branch of type-1.
Creates a new virtual edge replacing the parent edge,
and appends a bond component.
:param v: The current vertex.
:param lp1w_v: The lowpt1(w) vertex.
:param ve: The virtual edge for the split component.
:return: None
"""
peid: int | None = self.cur_parent_edge.get(v)
ve2: Edge = self._add_virt(lp1w_v, v)
self.cur_tree.add(ve2.id)
self.cur_parent_edge[v] = ve2.id
if peid is not None and self.g.has_edge(peid):
self.consumed.add(peid)
if peid in self.in_high:
self.in_high[ve2.id] = \
self.in_high.pop(peid)
self.raw_comps.append(
[ve.id, peid, ve2.id])
else:
self.raw_comps.append([ve.id, ve2.id])
def _check_type1(
self, v: Hashable, w: Hashable,
) -> None:
"""Check for type-1 separation pair (Algorithm 6).
Implements Algorithm 6 from Gutwenger-Mutzel (2001).
:param v: The current vertex label.
:param w: The child vertex (just processed).
:return: None
"""
v_num: int = self.dfs_num[v]
w_num: int = self.dfs_num[w]
lp1w: int = self.lowpt1[w]
lp2w: int = self.lowpt2[w]
if not (lp1w < v_num <= lp2w):
return
pv: Hashable | None = self.parent.get(v)
pv_num: int = (self.dfs_num.get(pv, 0)
if pv is not None else 0)
has_more: bool = _has_unvisited_arc(
v, w_num, self.cur_children, self.dfs_num)
if not (pv_num != 1 or has_more):
return
w_lo: int = w_num
w_hi: int = w_num + self.nd[w] - 1
comp_eids: list[int] = self._pop_subtree_edges(
w_lo, w_hi)
lp1w_v: Hashable = self.inv_dfs[lp1w]
ve: Edge = self._add_virt(v, lp1w_v)
comp_eids.append(ve.id)
self.raw_comps.append(comp_eids)
ve = self._type1_try_combine(v, lp1w_v, ve)
if lp1w != pv_num:
self.estack.append(ve.id)
self.adj_cache.setdefault(
v, []).append(ve.id)
self.cur_deg[v] += 1
self.cur_deg[lp1w_v] += 1
self.cur_tree.add(ve.id)
if (ve.id not in self.in_high
and self._high(lp1w) < v_num):
bisect.insort(
self.frond_srcs[lp1w], v_num)
self.in_high[ve.id] = (lp1w, v_num)
else:
self._type1_parent_bond(v, lp1w_v, ve)
ch: list[Hashable] = self.cur_children.get(v, [])
if w in ch:
ch.remove(w)
def _phase_pathsearch(
g: MultiGraph,
raw_comps: list[list[int]],
virtual_ids: set[int],
) -> None:
"""Detect separation pairs and split components via PathSearch.
Implements Algorithms 3-6 from Gutwenger-Mutzel (2001). Runs an
iterative DFS over the phi-sorted adjacency lists, maintaining an
edge stack (ESTACK) and a triple stack (TSTACK) to detect type-1
and type-2 separation pairs.
:param g: The working multigraph (modified in place).
:param raw_comps: List to append new split components to.
:param virtual_ids: Set to add new virtual edge IDs to.
:return: None
"""
searcher: _PathSearcher = _PathSearcher(
g, raw_comps, virtual_ids)
searcher.run()
def _has_unvisited_arc(
v: Hashable,
w_num: int,
cur_children: dict[Hashable, list[Hashable]],
dfs_num: dict[Hashable, int],
) -> bool:
"""Check if v has a tree child with DFS number > w_num.
Used in Algorithm 6 to determine whether v is adjacent to a
not-yet-visited tree arc (i.e., has another child after w).
:param v: The vertex to check.
:param w_num: DFS number of the current child w.
:param cur_children: Current DFS children for each vertex.
:param dfs_num: DFS discovery numbers.
:return: True if v has another child with dfs_num > w_num.
"""
for c in cur_children.get(v, []):
if dfs_num.get(c, 0) > w_num:
return True
return False
# ---------------------------------------------------------------------------
# Phase 3: Classify and merge split components
# ---------------------------------------------------------------------------
def _make_edge_list(
eids: list[int],
all_edges: dict[int, Edge],
) -> list[Edge]:
"""Resolve edge IDs to Edge objects (skipping unknowns).
:param eids: List of edge IDs.
:param all_edges: Mapping from edge ID to Edge object.
:return: List of Edge objects.
"""
result: list[Edge] = []
for eid in eids:
if eid in all_edges:
result.append(all_edges[eid])
return result
def _classify_component(
edges: list[Edge],
) -> ComponentType:
"""Classify a set of edges as BOND, POLYGON, or TRICONNECTED.
:param edges: The edges of the component.
:return: The ComponentType.
"""
verts: set[Hashable] = set()
for e in edges:
verts.add(e.u)
verts.add(e.v)
if len(verts) == 2:
return ComponentType.BOND
# Polygon: every vertex has degree 2.
deg_map: dict[Hashable, int] = dict.fromkeys(verts, 0)
for e in edges:
deg_map[e.u] += 1
deg_map[e.v] += 1
if all(d == 2 for d in deg_map.values()):
return ComponentType.POLYGON
return ComponentType.TRICONNECTED
def _phase_classify_merge(
raw_comps: list[list[int]],
virtual_ids: set[int],
orig_graph: MultiGraph,
work_graph: MultiGraph,
) -> list[TriconnectedComponent]:
"""Classify raw split components and merge same-type adjacent ones.
Classifies each split component as BOND, POLYGON, or TRICONNECTED.
Then merges adjacent components of the same type that share a virtual
edge (Algorithm 2).
:param raw_comps: Raw split components from Phases 1 and 2.
:param virtual_ids: Set of virtual edge IDs.
:param orig_graph: The original input graph.
:param work_graph: The working graph after PathSearch.
:return: Final list of TriconnectedComponent objects.
"""
# Build a unified edge dictionary.
all_edges: dict[int, Edge] = {}
for e in orig_graph.edges:
all_edges[e.id] = e
for e in work_graph.edges:
if e.id not in all_edges:
all_edges[e.id] = e
# Build classified components.
comps: list[TriconnectedComponent] = []
for eids in raw_comps:
edges: list[Edge] = _make_edge_list(eids, all_edges)
if len(edges) < 2:
continue
ctype: ComponentType = _classify_component(edges)
comps.append(TriconnectedComponent(
type=ctype, edges=edges))
if not comps:
return comps
# Merge adjacent same-type components (Algorithm 2).
return _merge_components(comps, virtual_ids)
def _uf_find(uf: list[int], x: int) -> int:
"""Find root of x's set in the union-find structure.
Uses path splitting for amortised near-constant time.
:param uf: The union-find parent array.
:param x: Index to find.
:return: Root index.
"""
while uf[x] != x:
uf[x] = uf[uf[x]]
x = uf[x]
return x
def _uf_union(uf: list[int], x: int, y: int) -> None:
"""Union the sets containing x and y.
:param uf: The union-find parent array.
:param x: First element.
:param y: Second element.
:return: None
"""
rx: int = _uf_find(uf, x)
ry: int = _uf_find(uf, y)
if rx != ry:
uf[rx] = ry
def _collect_merge_groups(
comps: list[TriconnectedComponent],
virtual_ids: set[int],
) -> tuple[dict[int, list[int]], set[int]]:
"""Identify groups of same-type components to merge.
Uses union-find to group components that share virtual edges
and have the same type.
:param comps: Initial classified components.
:param virtual_ids: Set of virtual edge IDs.
:return: A (groups, internal_ves) tuple where groups maps
representative index to list of member indices, and
internal_ves is the set of virtual edge IDs consumed
by merging.
"""
n: int = len(comps)
# Map: virtual edge ID -> list of component indices.
ve_to_comps: dict[int, list[int]] = {}
for i, comp in enumerate(comps):
for e in comp.edges:
if e.id in virtual_ids:
ve_to_comps.setdefault(e.id, []).append(i)
# Union-Find for merging groups.
uf: list[int] = list(range(n))
# Virtual edge IDs consumed by merging.
internal_ves: set[int] = set()
for veid, idxs in ve_to_comps.items():
if len(idxs) < 2:
continue
types_set: set[ComponentType] = {
comps[i].type for i in idxs}
if len(types_set) == 1:
for k in range(1, len(idxs)):
_uf_union(uf, idxs[0], idxs[k])
internal_ves.add(veid)
# Group component indices by representative.
groups: dict[int, list[int]] = {}
for i in range(n):
r: int = _uf_find(uf, i)
groups.setdefault(r, []).append(i)
return groups, internal_ves
def _merge_group_edges(
comps: list[TriconnectedComponent],
group_idxs: list[int],
internal_ves: set[int],
) -> TriconnectedComponent | None:
"""Merge edges from multiple same-type components.
Combines all edges from the group, removing internal virtual
edges that were shared between the merged components.
:param comps: All classified components.
:param group_idxs: Indices of components in this group.
:param internal_ves: Virtual edge IDs to exclude.
:return: A merged TriconnectedComponent, or None if fewer
than 2 edges remain.
"""
merged_type: ComponentType = comps[group_idxs[0]].type
seen_eids: set[int] = set()
merged_edges: list[Edge] = []
for idx in group_idxs:
for e in comps[idx].edges:
if e.id in seen_eids:
continue
seen_eids.add(e.id)
if e.id in internal_ves:
continue
merged_edges.append(e)
if len(merged_edges) >= 2:
return TriconnectedComponent(
type=merged_type, edges=merged_edges)
return None
def _build_merged_result(
comps: list[TriconnectedComponent],
groups: dict[int, list[int]],
internal_ves: set[int],
) -> list[TriconnectedComponent]:
"""Build the final merged component list from groups.
:param comps: All classified components.
:param groups: Mapping from representative to member indices.
:param internal_ves: Virtual edge IDs to exclude.
:return: Merged list of TriconnectedComponent objects.
"""
result: list[TriconnectedComponent] = []
for group_idxs in groups.values():
if len(group_idxs) == 1:
idx: int = group_idxs[0]
edges: list[Edge] = [
e for e in comps[idx].edges
if e.id not in internal_ves
]
result.append(TriconnectedComponent(
type=comps[idx].type, edges=edges))
continue
merged: TriconnectedComponent | None = \
_merge_group_edges(
comps, group_idxs, internal_ves)
if merged is not None:
result.append(merged)
return result
def _merge_components(
comps: list[TriconnectedComponent],
virtual_ids: set[int],
) -> list[TriconnectedComponent]:
"""Merge adjacent same-type components sharing a virtual edge.
Two or more components are adjacent if they share a virtual edge.
When all components sharing a virtual edge are the same type
(BOND+BOND or POLYGON+POLYGON), they are merged transitively: the
shared virtual edge is removed and the remaining edges are combined.
:param comps: Initial classified components.
:param virtual_ids: Set of virtual edge IDs.
:return: Merged list of TriconnectedComponent objects.
"""
if len(comps) == 0:
return comps
groups: dict[int, list[int]]
internal_ves: set[int]
groups, internal_ves = _collect_merge_groups(
comps, virtual_ids)
return _build_merged_result(comps, groups, internal_ves)