1496 lines
49 KiB
Python
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)
|