40
.readthedocs.yaml
Normal file
40
.readthedocs.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Pure Python SPQR-Tree implementation.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/5
|
||||
|
||||
# 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.
|
||||
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the OS, Python version, and other tools you might need
|
||||
build:
|
||||
os: ubuntu-24.04
|
||||
tools:
|
||||
python: "3.13"
|
||||
|
||||
# Build documentation in the "docs/" directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/source/conf.py
|
||||
|
||||
# Optionally, but recommended,
|
||||
# declare the Python requirements required to build your documentation
|
||||
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
202
LICENSE
Normal file
202
LICENSE
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
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.
|
||||
22
MANIFEST.in
Normal file
22
MANIFEST.in
Normal file
@@ -0,0 +1,22 @@
|
||||
# Pure Python SPQR-Tree implementation.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/4
|
||||
|
||||
# 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.
|
||||
|
||||
recursive-include docs *
|
||||
prune docs/build
|
||||
recursive-include tests *
|
||||
recursive-exclude tests *.pyc
|
||||
138
README.rst
Normal file
138
README.rst
Normal file
@@ -0,0 +1,138 @@
|
||||
========
|
||||
spqrtree
|
||||
========
|
||||
|
||||
|
||||
Description
|
||||
===========
|
||||
|
||||
**spqrtree** is a pure Python implementation of the SPQR-tree data
|
||||
structure for biconnected graphs. It decomposes a biconnected graph
|
||||
into its triconnected components (S, P, Q, and R nodes) and organizes
|
||||
them as a tree.
|
||||
|
||||
SPQR-trees are a classical tool in graph theory, widely used for
|
||||
planarity testing, graph drawing, and network analysis.
|
||||
|
||||
The implementation is based on the Gutwenger & Mutzel (2001) linear-time
|
||||
algorithm, with corrections to Hopcroft & Tarjan (1973), and follows the
|
||||
SPQR-tree data structure defined by Di Battista & Tamassia (1996).
|
||||
|
||||
Features:
|
||||
|
||||
- Pure Python --- no compiled extensions or external dependencies.
|
||||
- Handles multigraphs (parallel edges between the same vertex pair).
|
||||
- Simple ``dict``-based input for quick prototyping.
|
||||
- Typed package with PEP 561 support.
|
||||
- Requires Python 3.10 or later.
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
You can install spqrtree with ``pip``:
|
||||
|
||||
::
|
||||
|
||||
pip install spqrtree
|
||||
|
||||
You may also install the latest source from the
|
||||
`spqrtree GitHub repository`_.
|
||||
|
||||
::
|
||||
|
||||
pip install git+https://github.com/imacat/spqrtree.git
|
||||
|
||||
|
||||
Quick Start
|
||||
===========
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from spqrtree import SPQRTree, NodeType
|
||||
|
||||
# K4 complete graph
|
||||
graph = {
|
||||
1: [2, 3, 4],
|
||||
2: [1, 3, 4],
|
||||
3: [1, 2, 4],
|
||||
4: [1, 2, 3],
|
||||
}
|
||||
tree = SPQRTree(graph)
|
||||
print(tree.root.type) # NodeType.R
|
||||
print(len(tree.nodes())) # number of SPQR-tree nodes
|
||||
|
||||
|
||||
Documentation
|
||||
=============
|
||||
|
||||
Refer to the `documentation on Read the Docs`_.
|
||||
|
||||
|
||||
Change Log
|
||||
==========
|
||||
|
||||
Refer to the `change log`_.
|
||||
|
||||
|
||||
References
|
||||
==========
|
||||
|
||||
- C. Gutwenger and P. Mutzel, "A Linear Time Implementation of
|
||||
SPQR-Trees," *Graph Drawing (GD 2000)*, LNCS 1984, pp. 77--90,
|
||||
2001. `doi:10.1007/3-540-44541-2_8`_
|
||||
|
||||
- J. E. Hopcroft and R. E. Tarjan, "Dividing a Graph into
|
||||
Triconnected Components," *SIAM Journal on Computing*, 2(3),
|
||||
pp. 135--158, 1973. `doi:10.1137/0202012`_
|
||||
|
||||
- G. Di Battista and R. Tamassia, "On-Line Planarity Testing,"
|
||||
*SIAM Journal on Computing*, 25(5), pp. 956--997, 1996.
|
||||
`doi:10.1137/S0097539794280736`_
|
||||
|
||||
|
||||
Acknowledgments
|
||||
===============
|
||||
|
||||
This project was written from scratch in pure Python but drew
|
||||
inspiration from the `SageMath`_ project. The SPQR-tree
|
||||
implementation in SageMath served as a valuable reference for
|
||||
both its implementation approach and its comprehensive test
|
||||
cases.
|
||||
|
||||
Development was assisted by `Claude Code`_ (Anthropic).
|
||||
|
||||
|
||||
Copyright
|
||||
=========
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Authors
|
||||
=======
|
||||
|
||||
| imacat
|
||||
| imacat@mail.imacat.idv.tw
|
||||
| 2026/3/4
|
||||
|
||||
.. _spqrtree GitHub repository: https://github.com/imacat/spqrtree
|
||||
.. _documentation on Read the Docs: https://spqrtree.readthedocs.io
|
||||
.. _change log: https://spqrtree.readthedocs.io/en/latest/changelog.html
|
||||
.. _doi\:10.1007/3-540-44541-2_8: https://doi.org/10.1007/3-540-44541-2_8
|
||||
.. _doi\:10.1137/0202012: https://doi.org/10.1137/0202012
|
||||
.. _doi\:10.1137/S0097539794280736: https://doi.org/10.1137/S0097539794280736
|
||||
.. _SageMath: https://www.sagemath.org/
|
||||
.. _Claude Code: https://claude.com/claude-code
|
||||
20
docs/Makefile
Normal file
20
docs/Makefile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = source
|
||||
BUILDDIR = build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
35
docs/make.bat
Normal file
35
docs/make.bat
Normal file
@@ -0,0 +1,35 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=source
|
||||
set BUILDDIR=build
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.https://www.sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
||||
1
docs/requirements.txt
Normal file
1
docs/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
sphinx_rtd_theme
|
||||
0
docs/source/_static/.keep
Normal file
0
docs/source/_static/.keep
Normal file
0
docs/source/_templates/.keep
Normal file
0
docs/source/_templates/.keep
Normal file
3
docs/source/changelog.rst
Normal file
3
docs/source/changelog.rst
Normal file
@@ -0,0 +1,3 @@
|
||||
Change Log
|
||||
==========
|
||||
|
||||
33
docs/source/conf.py
Normal file
33
docs/source/conf.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath('../../src/'))
|
||||
import spqrtree
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
project = 'Pure Python SPQR-Tree Implementation'
|
||||
copyright = '2026, imacat'
|
||||
author = 'imacat'
|
||||
release = spqrtree.VERSION
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
extensions = ["sphinx.ext.autodoc"]
|
||||
|
||||
templates_path = ['_templates']
|
||||
exclude_patterns = []
|
||||
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_static_path = ['_static']
|
||||
25
docs/source/index.rst
Normal file
25
docs/source/index.rst
Normal file
@@ -0,0 +1,25 @@
|
||||
spqrtree
|
||||
========
|
||||
|
||||
A pure Python SPQR-tree implementation for biconnected graphs.
|
||||
|
||||
**spqrtree** decomposes a biconnected graph into its triconnected
|
||||
components and organizes them as an SPQR-tree, a classical data
|
||||
structure in graph theory used for planarity testing, graph drawing,
|
||||
and network analysis.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
intro
|
||||
spqrtree
|
||||
changelog
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
180
docs/source/intro.rst
Normal file
180
docs/source/intro.rst
Normal file
@@ -0,0 +1,180 @@
|
||||
Introduction
|
||||
============
|
||||
|
||||
What is an SPQR-Tree?
|
||||
---------------------
|
||||
|
||||
An SPQR-tree is a tree data structure that represents the decomposition
|
||||
of a biconnected graph into its triconnected components. Each node of
|
||||
the tree corresponds to one of four types:
|
||||
|
||||
- **S-node** (series): a simple cycle.
|
||||
- **P-node** (parallel): a bundle of parallel edges between two poles.
|
||||
- **Q-node**: a single real edge (degenerate case).
|
||||
- **R-node** (rigid): a 3-connected subgraph that cannot be further
|
||||
decomposed.
|
||||
|
||||
SPQR-trees are widely used in graph drawing, planarity testing,
|
||||
and network reliability analysis.
|
||||
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- Pure Python --- no compiled extensions or external dependencies.
|
||||
- Handles multigraphs (parallel edges between the same vertex pair).
|
||||
- Implements the Gutwenger & Mutzel (2001) algorithm with corrections
|
||||
to Hopcroft & Tarjan (1973).
|
||||
- Simple ``dict``-based input for quick prototyping.
|
||||
- Typed package with :pep:`561` support.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
Install from `PyPI <https://pypi.org/project/spqrtree/>`_ with pip:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install spqrtree
|
||||
|
||||
Or install the latest development version from GitHub:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install git+https://github.com/imacat/spqrtree.git
|
||||
|
||||
Requires Python 3.10 or later.
|
||||
|
||||
|
||||
Quick Start
|
||||
-----------
|
||||
|
||||
Build an SPQR-tree from an adjacency-list dictionary:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from spqrtree import SPQRTree
|
||||
|
||||
# A simple diamond graph (K4 minus one edge)
|
||||
graph = {
|
||||
1: [2, 3, 4],
|
||||
2: [1, 3, 4],
|
||||
3: [1, 2, 4],
|
||||
4: [1, 2, 3],
|
||||
}
|
||||
tree = SPQRTree(graph)
|
||||
print(tree.root.type) # NodeType.R
|
||||
print(len(tree.nodes())) # number of SPQR-tree nodes
|
||||
|
||||
The input dictionary maps each vertex to its list of neighbors. For
|
||||
each pair ``(u, v)`` where ``u < v``, one edge is added.
|
||||
|
||||
|
||||
Usage Guide
|
||||
-----------
|
||||
|
||||
Inspecting the Tree
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The :class:`~spqrtree.SPQRTree` object exposes two main attributes:
|
||||
|
||||
- :attr:`~spqrtree.SPQRTree.root` --- the root
|
||||
:class:`~spqrtree.SPQRNode` of the tree.
|
||||
- :meth:`~spqrtree.SPQRTree.nodes` --- all nodes in BFS order.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from spqrtree import SPQRTree, NodeType
|
||||
|
||||
graph = {
|
||||
0: [1, 2],
|
||||
1: [0, 2, 3],
|
||||
2: [0, 1, 3],
|
||||
3: [1, 2],
|
||||
}
|
||||
tree = SPQRTree(graph)
|
||||
|
||||
for node in tree.nodes():
|
||||
print(node.type, node.poles)
|
||||
|
||||
Each :class:`~spqrtree.SPQRNode` has the following attributes:
|
||||
|
||||
- ``type`` --- a :class:`~spqrtree.NodeType` enum (``S``, ``P``,
|
||||
``Q``, or ``R``).
|
||||
- ``skeleton`` --- the skeleton graph containing the real and virtual
|
||||
edges of the component.
|
||||
- ``poles`` --- the two vertices shared with the parent component.
|
||||
- ``parent`` --- the parent node (``None`` for the root).
|
||||
- ``children`` --- the list of child nodes.
|
||||
|
||||
|
||||
Understanding Node Types
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**S-node (series):**
|
||||
Represents a cycle. The skeleton is a simple polygon whose edges
|
||||
alternate between real edges and virtual edges leading to children.
|
||||
|
||||
**P-node (parallel):**
|
||||
Represents parallel edges between two pole vertices. The skeleton
|
||||
contains three or more edges (real and/or virtual) between the same
|
||||
pair of poles.
|
||||
|
||||
**R-node (rigid):**
|
||||
Represents a 3-connected component that cannot be further decomposed
|
||||
by any separation pair. The skeleton is a 3-connected graph.
|
||||
|
||||
**Q-node:**
|
||||
Represents a single real edge. Q-nodes appear as leaves of the tree.
|
||||
|
||||
|
||||
Using a MultiGraph
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
For more control, build a
|
||||
:class:`~spqrtree._graph.MultiGraph` directly:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from spqrtree._graph import MultiGraph
|
||||
from spqrtree import SPQRTree
|
||||
|
||||
g = MultiGraph()
|
||||
g.add_edge(0, 1)
|
||||
g.add_edge(1, 2)
|
||||
g.add_edge(2, 0)
|
||||
g.add_edge(1, 3)
|
||||
g.add_edge(3, 2)
|
||||
|
||||
tree = SPQRTree(g)
|
||||
|
||||
|
||||
References
|
||||
----------
|
||||
|
||||
The implementation is based on the following papers:
|
||||
|
||||
- J. Hopcroft and R. Tarjan, "Dividing a graph into triconnected
|
||||
components," *SIAM Journal on Computing*, vol. 2, no. 3,
|
||||
pp. 135--158, 1973.
|
||||
`doi:10.1137/0202012 <https://doi.org/10.1137/0202012>`_
|
||||
|
||||
- G. Di Battista and R. Tamassia, "On-line planarity testing,"
|
||||
*SIAM Journal on Computing*, vol. 25, no. 5, pp. 956--997, 1996.
|
||||
`doi:10.1137/S0097539794280736
|
||||
<https://doi.org/10.1137/S0097539794280736>`_
|
||||
|
||||
- C. Gutwenger and P. Mutzel, "A linear time implementation of
|
||||
SPQR-trees," *Proc. 8th International Symposium on Graph Drawing
|
||||
(GD 2000)*, LNCS 1984, pp. 77--90, Springer, 2001.
|
||||
`doi:10.1007/3-540-44541-2_8
|
||||
<https://doi.org/10.1007/3-540-44541-2_8>`_
|
||||
|
||||
|
||||
Acknowledgments
|
||||
---------------
|
||||
|
||||
The test suite was validated against the SPQR-tree implementation in
|
||||
`SageMath <https://www.sagemath.org/>`_, which served as the reference
|
||||
for verifying correctness of the decomposition results.
|
||||
7
docs/source/modules.rst
Normal file
7
docs/source/modules.rst
Normal file
@@ -0,0 +1,7 @@
|
||||
src
|
||||
===
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 4
|
||||
|
||||
spqrtree
|
||||
10
docs/source/spqrtree.rst
Normal file
10
docs/source/spqrtree.rst
Normal file
@@ -0,0 +1,10 @@
|
||||
spqrtree package
|
||||
================
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: spqrtree
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
68
pyproject.toml
Normal file
68
pyproject.toml
Normal file
@@ -0,0 +1,68 @@
|
||||
# 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.
|
||||
|
||||
[project]
|
||||
name = "spqrtree"
|
||||
dynamic = ["version"]
|
||||
description = "Pure Python SPQR-Tree implementation"
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "Apache-2.0" }
|
||||
authors = [
|
||||
{ name = "imacat", email = "imacat@mail.imacat.idv.tw" },
|
||||
]
|
||||
keywords = [
|
||||
"spqr",
|
||||
"spqr-tree",
|
||||
"graph",
|
||||
"triconnected",
|
||||
"triconnectivity",
|
||||
"graph-decomposition",
|
||||
"biconnected",
|
||||
"separation-pair",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering :: Mathematics",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/imacat/spqrtree"
|
||||
Repository = "https://github.com/imacat/spqrtree"
|
||||
Documentation = "https://spqrtree.readthedocs.io"
|
||||
"Change Log" = "https://spqrtree.readthedocs.io/en/latest/changelog.html"
|
||||
"Bug Tracker" = "https://github.com/imacat/spqrtree/issues"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "spqrtree.VERSION"}
|
||||
105
src/spqrtree/__init__.py
Normal file
105
src/spqrtree/__init__.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# Pure Python SPQR-Tree implementation.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/1
|
||||
# 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.
|
||||
"""SPQR-Tree: a pure Python implementation.
|
||||
|
||||
This package provides an SPQR-Tree data structure for biconnected graphs,
|
||||
based on the algorithm by Gutwenger & Mutzel (2001) with corrections to
|
||||
Hopcroft-Tarjan (1973), and data structure definitions from
|
||||
Di Battista & Tamassia (1996).
|
||||
|
||||
Public API::
|
||||
|
||||
from spqrtree import Edge, MultiGraph
|
||||
from spqrtree import NodeType, SPQRNode, build_spqr_tree
|
||||
from spqrtree import (
|
||||
ComponentType,
|
||||
TriconnectedComponent,
|
||||
find_triconnected_components,
|
||||
)
|
||||
"""
|
||||
from collections import deque
|
||||
|
||||
from spqrtree._graph import Edge, MultiGraph
|
||||
from spqrtree._spqr import NodeType, SPQRNode, build_spqr_tree
|
||||
from spqrtree._triconnected import (
|
||||
ComponentType,
|
||||
TriconnectedComponent,
|
||||
find_triconnected_components,
|
||||
)
|
||||
|
||||
VERSION: str = "0.0.0"
|
||||
"""The package version."""
|
||||
__all__: list[str] = [
|
||||
"SPQRTree", "SPQRNode", "NodeType",
|
||||
"Edge", "MultiGraph",
|
||||
"build_spqr_tree",
|
||||
"ComponentType", "TriconnectedComponent",
|
||||
"find_triconnected_components",
|
||||
]
|
||||
|
||||
|
||||
class SPQRTree:
|
||||
"""An SPQR-Tree for a biconnected graph.
|
||||
|
||||
Constructs the SPQR-Tree from a biconnected multigraph,
|
||||
using the Gutwenger-Mutzel triconnected components algorithm.
|
||||
"""
|
||||
|
||||
def __init__(self, graph: MultiGraph | dict) -> None:
|
||||
"""Initialize the SPQR-Tree from a graph.
|
||||
|
||||
:param graph: A MultiGraph or dict representing the input graph.
|
||||
:return: None
|
||||
"""
|
||||
if isinstance(graph, dict):
|
||||
g: MultiGraph = MultiGraph()
|
||||
seen: set[frozenset] = set()
|
||||
for u, neighbors in graph.items():
|
||||
g.add_vertex(u)
|
||||
for v in neighbors:
|
||||
pair: frozenset = frozenset((u, v))
|
||||
if pair not in seen:
|
||||
seen.add(pair)
|
||||
g.add_edge(u, v)
|
||||
else:
|
||||
g = graph
|
||||
self._root: SPQRNode = build_spqr_tree(g)
|
||||
"""The root node of the SPQR-Tree."""
|
||||
|
||||
@property
|
||||
def root(self) -> SPQRNode:
|
||||
"""Return the root node of the SPQR-Tree.
|
||||
|
||||
:return: The root SPQRNode.
|
||||
"""
|
||||
return self._root
|
||||
|
||||
def nodes(self) -> list[SPQRNode]:
|
||||
"""Return all nodes of the SPQR-Tree in BFS order.
|
||||
|
||||
:return: A list of all SPQRNode objects.
|
||||
"""
|
||||
result: list[SPQRNode] = []
|
||||
bfs_queue: deque[SPQRNode] = deque([self._root])
|
||||
while bfs_queue:
|
||||
node: SPQRNode = bfs_queue.popleft()
|
||||
result.append(node)
|
||||
for child in node.children:
|
||||
bfs_queue.append(child)
|
||||
return result
|
||||
306
src/spqrtree/_graph.py
Normal file
306
src/spqrtree/_graph.py
Normal file
@@ -0,0 +1,306 @@
|
||||
# Pure Python SPQR-Tree implementation.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/1
|
||||
# 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.
|
||||
"""Internal multigraph data structure for the SPQR-Tree algorithm.
|
||||
|
||||
Provides Edge and MultiGraph classes supporting parallel edges and
|
||||
virtual edges, identified by integer IDs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Hashable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Edge:
|
||||
"""An edge in a multigraph, identified by a unique integer ID.
|
||||
|
||||
Supports parallel edges (multiple edges between the same pair of
|
||||
vertices) and virtual edges used internally by the SPQR-Tree
|
||||
algorithm.
|
||||
"""
|
||||
|
||||
id: int
|
||||
"""Unique integer identifier for this edge."""
|
||||
|
||||
u: Hashable
|
||||
"""One endpoint of the edge."""
|
||||
|
||||
v: Hashable
|
||||
"""The other endpoint of the edge."""
|
||||
|
||||
virtual: bool = False
|
||||
"""Whether this is a virtual edge (used in SPQR skeletons)."""
|
||||
|
||||
def endpoints(self) -> tuple[Hashable, Hashable]:
|
||||
"""Return both endpoints as a tuple.
|
||||
|
||||
:return: A tuple (u, v) of the two endpoints.
|
||||
"""
|
||||
return self.u, self.v
|
||||
|
||||
def other(self, vertex: Hashable) -> Hashable:
|
||||
"""Return the endpoint opposite to the given vertex.
|
||||
|
||||
:param vertex: One endpoint of this edge.
|
||||
:return: The other endpoint.
|
||||
:raises ValueError: If vertex is not an endpoint of this edge.
|
||||
"""
|
||||
if vertex == self.u:
|
||||
return self.v
|
||||
if vertex == self.v:
|
||||
return self.u
|
||||
raise ValueError(
|
||||
f"Vertex {vertex} is not an endpoint of edge {self.id}"
|
||||
)
|
||||
|
||||
|
||||
class MultiGraph:
|
||||
"""An undirected multigraph supporting parallel edges and virtual edges.
|
||||
|
||||
Vertices are arbitrary hashable values. Edges are identified by
|
||||
unique integer IDs. Supports parallel edges (multiple edges between
|
||||
the same vertex pair).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty multigraph.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._vertices: set[Hashable] = set()
|
||||
"""The set of vertices in this graph."""
|
||||
|
||||
self._edges: dict[int, Edge] = {}
|
||||
"""Map from edge ID to Edge object."""
|
||||
|
||||
self._adj: dict[Hashable, list[int]] = {}
|
||||
"""Adjacency list: vertex -> list of edge IDs."""
|
||||
|
||||
self._next_edge_id: int = 0
|
||||
"""Counter for assigning unique edge IDs."""
|
||||
|
||||
@property
|
||||
def vertices(self) -> set[Hashable]:
|
||||
"""Return the set of all vertices.
|
||||
|
||||
:return: The set of vertices.
|
||||
"""
|
||||
return self._vertices
|
||||
|
||||
@property
|
||||
def edges(self) -> list[Edge]:
|
||||
"""Return a list of all edges in the graph.
|
||||
|
||||
:return: List of Edge objects.
|
||||
"""
|
||||
return list(self._edges.values())
|
||||
|
||||
def add_vertex(self, v: Hashable) -> None:
|
||||
"""Add a vertex to the graph. No-op if already present.
|
||||
|
||||
:param v: The vertex to add.
|
||||
:return: None
|
||||
"""
|
||||
if v not in self._vertices:
|
||||
self._vertices.add(v)
|
||||
self._adj[v] = []
|
||||
|
||||
def remove_vertex(self, v: Hashable) -> None:
|
||||
"""Remove a vertex and all its incident edges from the graph.
|
||||
|
||||
:param v: The vertex to remove.
|
||||
:return: None
|
||||
:raises KeyError: If the vertex does not exist.
|
||||
"""
|
||||
if v not in self._vertices:
|
||||
raise KeyError(f"Vertex {v} not in graph")
|
||||
edge_ids: list[int] = list(self._adj[v])
|
||||
for eid in edge_ids:
|
||||
self.remove_edge(eid)
|
||||
self._vertices.remove(v)
|
||||
del self._adj[v]
|
||||
|
||||
def add_edge(
|
||||
self, u: Hashable, v: Hashable, virtual: bool = False
|
||||
) -> Edge:
|
||||
"""Add an edge between two vertices and return it.
|
||||
|
||||
Automatically adds vertices u and v if not already present.
|
||||
|
||||
:param u: One endpoint vertex.
|
||||
:param v: The other endpoint vertex.
|
||||
:param virtual: Whether this is a virtual edge.
|
||||
:return: The newly created Edge object.
|
||||
"""
|
||||
self.add_vertex(u)
|
||||
self.add_vertex(v)
|
||||
eid: int = self._next_edge_id
|
||||
self._next_edge_id += 1
|
||||
e: Edge = Edge(id=eid, u=u, v=v, virtual=virtual)
|
||||
self._edges[eid] = e
|
||||
self._adj[u].append(eid)
|
||||
if u != v:
|
||||
self._adj[v].append(eid)
|
||||
return e
|
||||
|
||||
def remove_edge(self, edge_id: int) -> None:
|
||||
"""Remove an edge from the graph by its ID.
|
||||
|
||||
:param edge_id: The integer ID of the edge to remove.
|
||||
:return: None
|
||||
:raises KeyError: If the edge ID does not exist.
|
||||
"""
|
||||
if edge_id not in self._edges:
|
||||
raise KeyError(f"Edge {edge_id} not in graph")
|
||||
e: Edge = self._edges.pop(edge_id)
|
||||
self._adj[e.u].remove(edge_id)
|
||||
if e.u != e.v:
|
||||
self._adj[e.v].remove(edge_id)
|
||||
|
||||
def get_edge(self, edge_id: int) -> Edge | None:
|
||||
"""Return the edge with the given ID, or None if not found.
|
||||
|
||||
:param edge_id: The integer ID of the edge to look up.
|
||||
:return: The Edge object, or None if no such edge exists.
|
||||
"""
|
||||
return self._edges.get(edge_id)
|
||||
|
||||
def has_edge(self, edge_id: int) -> bool:
|
||||
"""Check whether an edge with the given ID exists.
|
||||
|
||||
:param edge_id: The integer ID to check.
|
||||
:return: True if the edge exists, False otherwise.
|
||||
"""
|
||||
return edge_id in self._edges
|
||||
|
||||
def neighbors(self, v: Hashable) -> list[Hashable]:
|
||||
"""Return a list of distinct vertices adjacent to v.
|
||||
|
||||
:param v: A vertex.
|
||||
:return: List of adjacent vertices (no duplicates).
|
||||
:raises KeyError: If vertex v does not exist.
|
||||
"""
|
||||
if v not in self._vertices:
|
||||
raise KeyError(f"Vertex {v} not in graph")
|
||||
seen: set[Hashable] = set()
|
||||
result: list[Hashable] = []
|
||||
for eid in self._adj[v]:
|
||||
nbr: Hashable = self._edges[eid].other(v)
|
||||
if nbr not in seen:
|
||||
seen.add(nbr)
|
||||
result.append(nbr)
|
||||
return result
|
||||
|
||||
def incident_edges(self, v: Hashable) -> list[Edge]:
|
||||
"""Return all edges incident to vertex v.
|
||||
|
||||
:param v: A vertex.
|
||||
:return: List of Edge objects incident to v.
|
||||
:raises KeyError: If vertex v does not exist.
|
||||
"""
|
||||
if v not in self._vertices:
|
||||
raise KeyError(f"Vertex {v} not in graph")
|
||||
return [self._edges[eid] for eid in self._adj[v]]
|
||||
|
||||
def edges_between(self, u: Hashable, v: Hashable) -> list[Edge]:
|
||||
"""Return all edges between vertices u and v.
|
||||
|
||||
:param u: One vertex.
|
||||
:param v: The other vertex.
|
||||
:return: List of Edge objects between u and v.
|
||||
"""
|
||||
return [
|
||||
self._edges[eid]
|
||||
for eid in self._adj.get(u, [])
|
||||
if self._edges[eid].other(u) == v
|
||||
]
|
||||
|
||||
def degree(self, v: Hashable) -> int:
|
||||
"""Return the degree of vertex v (number of incident edges).
|
||||
|
||||
:param v: A vertex.
|
||||
:return: The number of edges incident to v.
|
||||
:raises KeyError: If vertex v does not exist.
|
||||
"""
|
||||
if v not in self._vertices:
|
||||
raise KeyError(f"Vertex {v} not in graph")
|
||||
return len(self._adj[v])
|
||||
|
||||
def num_vertices(self) -> int:
|
||||
"""Return the number of vertices in the graph.
|
||||
|
||||
:return: Vertex count.
|
||||
"""
|
||||
return len(self._vertices)
|
||||
|
||||
def num_edges(self) -> int:
|
||||
"""Return the number of edges in the graph.
|
||||
|
||||
:return: Edge count.
|
||||
"""
|
||||
return len(self._edges)
|
||||
|
||||
def copy(self) -> MultiGraph:
|
||||
"""Return a shallow copy of this graph with independent structure.
|
||||
|
||||
Vertices and edge IDs are preserved. Edge objects are copied.
|
||||
|
||||
:return: A new MultiGraph with the same structure.
|
||||
"""
|
||||
g: MultiGraph = MultiGraph()
|
||||
g._next_edge_id = self._next_edge_id
|
||||
for v in self._vertices:
|
||||
g._vertices.add(v)
|
||||
g._adj[v] = list(self._adj[v])
|
||||
for eid, e in self._edges.items():
|
||||
g._edges[eid] = Edge(
|
||||
id=e.id, u=e.u, v=e.v, virtual=e.virtual
|
||||
)
|
||||
return g
|
||||
|
||||
def adj_edge_ids(self, v: Hashable) -> list[int]:
|
||||
"""Return the list of edge IDs in the adjacency list of v.
|
||||
|
||||
The order reflects the current adjacency list ordering, which
|
||||
may be modified by the palm tree construction for algorithmic
|
||||
correctness.
|
||||
|
||||
:param v: A vertex.
|
||||
:return: List of edge IDs adjacent to v, in adjacency list order.
|
||||
:raises KeyError: If vertex v does not exist.
|
||||
"""
|
||||
if v not in self._vertices:
|
||||
raise KeyError(f"Vertex {v} not in graph")
|
||||
return list(self._adj[v])
|
||||
|
||||
def set_adj_order(self, v: Hashable, edge_ids: list[int]) -> None:
|
||||
"""Set the adjacency list order for vertex v.
|
||||
|
||||
Used by the palm tree algorithm to reorder edges for correct
|
||||
DFS traversal order per Gutwenger-Mutzel Section 4.2.
|
||||
|
||||
:param v: A vertex.
|
||||
:param edge_ids: The ordered list of edge IDs for v's adjacency.
|
||||
:return: None
|
||||
:raises KeyError: If vertex v does not exist.
|
||||
"""
|
||||
if v not in self._vertices:
|
||||
raise KeyError(f"Vertex {v} not in graph")
|
||||
self._adj[v] = list(edge_ids)
|
||||
363
src/spqrtree/_palm_tree.py
Normal file
363
src/spqrtree/_palm_tree.py
Normal file
@@ -0,0 +1,363 @@
|
||||
# Pure Python SPQR-Tree implementation.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/1
|
||||
# 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.
|
||||
"""Palm tree construction for the SPQR-Tree algorithm.
|
||||
|
||||
Implements a DFS-based palm tree computation as required by the
|
||||
Gutwenger-Mutzel (2001) triconnected components algorithm.
|
||||
|
||||
A palm tree is a DFS spanning tree together with all back edges (fronds).
|
||||
For each vertex v, we compute:
|
||||
|
||||
- ``dfs_num[v]``: DFS discovery order number (1-indexed).
|
||||
- ``parent[v]``: parent vertex in the DFS tree (None for root).
|
||||
- ``parent_edge[v]``: edge ID of the parent edge (None for root).
|
||||
- ``tree_edges``: set of tree edge IDs.
|
||||
- ``fronds``: set of frond (back edge) IDs.
|
||||
- ``lowpt1[v]``: minimum DFS number reachable from the subtree of v
|
||||
(including v itself) via at most one frond.
|
||||
- ``lowpt2[v]``: second minimum DFS number in the same candidate set,
|
||||
i.e., the smallest value strictly greater than lowpt1[v].
|
||||
Set to ``LOWPT_INF`` if no such value exists.
|
||||
- ``nd[v]``: number of vertices in the subtree rooted at v (including v).
|
||||
- ``first_child[v]``: first DFS tree child of v (None if v is a leaf).
|
||||
- ``high[v]``: highest DFS number of a descendant of v (inclusive) that
|
||||
has a frond to a proper ancestor of v. Zero if no such frond exists.
|
||||
"""
|
||||
from collections.abc import Hashable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from spqrtree._graph import Edge, MultiGraph
|
||||
|
||||
LOWPT_INF: int = 10 ** 9
|
||||
"""Sentinel value representing positive infinity for lowpt computations."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PalmTree:
|
||||
"""A DFS palm tree with low-point values for a connected multigraph.
|
||||
|
||||
All vertex-keyed dictionaries are defined for every vertex in the
|
||||
graph. Edge-ID sets cover every edge exactly once.
|
||||
"""
|
||||
|
||||
dfs_num: dict[Hashable, int]
|
||||
"""DFS discovery number for each vertex (1-indexed)."""
|
||||
|
||||
parent: dict[Hashable, Hashable | None]
|
||||
"""Parent vertex in the DFS tree; None for the root."""
|
||||
|
||||
parent_edge: dict[Hashable, int | None]
|
||||
"""Edge ID of the tree edge to the parent; None for the root."""
|
||||
|
||||
tree_edges: set[int]
|
||||
"""Set of edge IDs classified as DFS tree edges."""
|
||||
|
||||
fronds: set[int]
|
||||
"""Set of edge IDs classified as fronds (back edges)."""
|
||||
|
||||
lowpt1: dict[Hashable, int]
|
||||
"""Minimum DFS number reachable from the subtree of each vertex."""
|
||||
|
||||
lowpt2: dict[Hashable, int]
|
||||
"""Second minimum DFS number reachable; LOWPT_INF if none."""
|
||||
|
||||
nd: dict[Hashable, int]
|
||||
"""Number of vertices in the subtree rooted at each vertex."""
|
||||
|
||||
first_child: dict[Hashable, Hashable | None]
|
||||
"""First DFS tree child of each vertex; None if the vertex is a leaf."""
|
||||
|
||||
high: dict[Hashable, int]
|
||||
"""Highest descendant DFS number with a frond to a proper ancestor."""
|
||||
|
||||
children: dict[Hashable, list[Hashable]] = field(
|
||||
default_factory=dict)
|
||||
"""Ordered list of DFS tree children for each vertex."""
|
||||
|
||||
dfs_order: list[Hashable] = field(default_factory=list)
|
||||
"""Vertices in DFS discovery order (root first)."""
|
||||
|
||||
|
||||
def _is_frond_edge(
|
||||
w: Hashable,
|
||||
v: Hashable,
|
||||
eid: int,
|
||||
dfs_num: dict[Hashable, int],
|
||||
parent_edge: dict[Hashable, int | None],
|
||||
) -> bool:
|
||||
"""Check if edge *eid* from *v* to *w* is a frond (back edge).
|
||||
|
||||
A frond goes from v to a proper ancestor w (lower DFS number)
|
||||
and is not the tree edge connecting v to its parent.
|
||||
|
||||
:param w: The other endpoint of the edge.
|
||||
:param v: The current vertex.
|
||||
:param eid: The edge ID.
|
||||
:param dfs_num: DFS discovery numbers.
|
||||
:param parent_edge: Parent edge IDs.
|
||||
:return: True if the edge is a frond.
|
||||
"""
|
||||
return dfs_num[w] < dfs_num[v] and eid != parent_edge[v]
|
||||
|
||||
|
||||
def build_palm_tree(graph: MultiGraph, start: Hashable) -> PalmTree:
|
||||
"""Build a DFS palm tree for the given graph starting at *start*.
|
||||
|
||||
Performs an iterative DFS in adjacency-list order (edge insertion
|
||||
order). Computes DFS numbers, parent links, tree/frond
|
||||
classification, lowpt1/lowpt2, nd, first_child, and high values.
|
||||
|
||||
:param graph: The multigraph to traverse.
|
||||
:param start: The starting vertex for the DFS.
|
||||
:return: A fully populated PalmTree.
|
||||
:raises KeyError: If *start* is not in the graph.
|
||||
"""
|
||||
dfs_num: dict[Hashable, int] = {}
|
||||
parent: dict[Hashable, Hashable | None] = {start: None}
|
||||
parent_edge: dict[Hashable, int | None] = {start: None}
|
||||
tree_edges: set[int] = set()
|
||||
fronds: set[int] = set()
|
||||
lowpt1: dict[Hashable, int] = {}
|
||||
lowpt2: dict[Hashable, int] = {}
|
||||
nd: dict[Hashable, int] = {}
|
||||
first_child: dict[Hashable, Hashable | None] = {}
|
||||
high: dict[Hashable, int] = {}
|
||||
children: dict[Hashable, list[Hashable]] = {
|
||||
v: [] for v in graph.vertices}
|
||||
dfs_order: list[Hashable] = []
|
||||
|
||||
counter: int = 0
|
||||
|
||||
# Stack entries: (vertex, iterator-index into adjacency list)
|
||||
stack: list[tuple[Hashable, int]] = [(start, 0)]
|
||||
visited: set[Hashable] = set()
|
||||
adj_lists: dict[Hashable, list[int]] = {
|
||||
v: graph.adj_edge_ids(v) for v in graph.vertices
|
||||
}
|
||||
|
||||
while stack:
|
||||
v, idx = stack[-1]
|
||||
|
||||
if v not in visited:
|
||||
visited.add(v)
|
||||
counter += 1
|
||||
dfs_num[v] = counter
|
||||
dfs_order.append(v)
|
||||
first_child[v] = None
|
||||
|
||||
adj: list[int] = adj_lists[v]
|
||||
advanced: bool = False
|
||||
|
||||
while idx < len(adj):
|
||||
eid: int = adj[idx]
|
||||
idx += 1
|
||||
e: Edge | None = graph.get_edge(eid)
|
||||
assert e is not None
|
||||
w: Hashable = e.other(v)
|
||||
|
||||
if w not in visited:
|
||||
# Tree edge v -> w
|
||||
tree_edges.add(eid)
|
||||
parent[w] = v
|
||||
parent_edge[w] = eid
|
||||
children[v].append(w)
|
||||
if first_child[v] is None:
|
||||
first_child[v] = w
|
||||
stack[-1] = (v, idx)
|
||||
stack.append((w, 0))
|
||||
advanced = True
|
||||
break
|
||||
elif _is_frond_edge(w, v, eid, dfs_num, parent_edge):
|
||||
# Frond v -> w (w is a proper ancestor of v)
|
||||
fronds.add(eid)
|
||||
# else: w is a descendant or parent edge (already handled)
|
||||
|
||||
if not advanced:
|
||||
# All adjacencies of v processed; compute bottom-up values
|
||||
stack.pop()
|
||||
_compute_lowpt(
|
||||
v, dfs_num, children, fronds,
|
||||
graph, lowpt1, lowpt2, nd, high
|
||||
)
|
||||
|
||||
return PalmTree(
|
||||
dfs_num=dfs_num,
|
||||
parent=parent,
|
||||
parent_edge=parent_edge,
|
||||
tree_edges=tree_edges,
|
||||
fronds=fronds,
|
||||
lowpt1=lowpt1,
|
||||
lowpt2=lowpt2,
|
||||
nd=nd,
|
||||
first_child=first_child,
|
||||
high=high,
|
||||
children=children,
|
||||
dfs_order=dfs_order,
|
||||
)
|
||||
|
||||
|
||||
def _compute_lowpt(
|
||||
v: Hashable,
|
||||
dfs_num: dict[Hashable, int],
|
||||
children: dict[Hashable, list[Hashable]],
|
||||
fronds: set[int],
|
||||
graph: MultiGraph,
|
||||
lowpt1: dict[Hashable, int],
|
||||
lowpt2: dict[Hashable, int],
|
||||
nd: dict[Hashable, int],
|
||||
high: dict[Hashable, int],
|
||||
) -> None:
|
||||
"""Compute lowpt1, lowpt2, nd, and high for vertex v.
|
||||
|
||||
Called in post-order (after all children of v are processed).
|
||||
Updates the dictionaries in place.
|
||||
|
||||
:param v: The vertex being processed.
|
||||
:param dfs_num: DFS discovery numbers for all vertices.
|
||||
:param children: DFS tree children for all vertices.
|
||||
:param fronds: Set of frond edge IDs.
|
||||
:param graph: The multigraph.
|
||||
:param lowpt1: Output dictionary for lowpt1 values.
|
||||
:param lowpt2: Output dictionary for lowpt2 values.
|
||||
:param nd: Output dictionary for subtree sizes.
|
||||
:param high: Output dictionary for high values.
|
||||
:return: None
|
||||
"""
|
||||
# Candidate multiset for low-point computation.
|
||||
# We only need to track the two smallest distinct values.
|
||||
lp1: int = dfs_num[v]
|
||||
lp2: int = LOWPT_INF
|
||||
|
||||
def _update(val: int) -> None:
|
||||
"""Update (lp1, lp2) with a new candidate value.
|
||||
|
||||
:param val: A new candidate DFS number.
|
||||
:return: None
|
||||
"""
|
||||
nonlocal lp1, lp2
|
||||
if val < lp1:
|
||||
lp2 = lp1
|
||||
lp1 = val
|
||||
elif lp1 < val < lp2:
|
||||
lp2 = val
|
||||
|
||||
# Fronds from v directly
|
||||
for eid in graph.adj_edge_ids(v):
|
||||
if eid in fronds:
|
||||
e: Edge | None = graph.get_edge(eid)
|
||||
assert e is not None
|
||||
w: Hashable = e.other(v)
|
||||
_update(dfs_num[w])
|
||||
|
||||
# Propagate from children
|
||||
nd_sum: int = 1
|
||||
high_max: int = 0
|
||||
for c in children[v]:
|
||||
_update(lowpt1[c])
|
||||
if lowpt2[c] != LOWPT_INF:
|
||||
_update(lowpt2[c])
|
||||
nd_sum += nd[c]
|
||||
if high[c] > high_max:
|
||||
high_max = high[c]
|
||||
|
||||
lowpt1[v] = lp1
|
||||
lowpt2[v] = lp2
|
||||
nd[v] = nd_sum
|
||||
|
||||
# high[v]: the highest DFS number of a descendant (or v) that has
|
||||
# a frond to a proper ancestor of v.
|
||||
# A frond (v, w) goes to a proper ancestor if dfs_num[w] < dfs_num[v].
|
||||
if any(eid in fronds for eid in graph.adj_edge_ids(v)):
|
||||
high_max = max(high_max, dfs_num[v])
|
||||
high[v] = high_max
|
||||
|
||||
|
||||
def phi_key(
|
||||
v: Hashable,
|
||||
eid: int,
|
||||
pt: PalmTree,
|
||||
graph: MultiGraph,
|
||||
) -> int:
|
||||
"""Compute the sort key φ(e) for edge eid as defined by Gutwenger-Mutzel.
|
||||
|
||||
The φ ordering governs the adjacency list order used during the main
|
||||
DFS in Algorithm 3 (PathSearch). Smaller φ values come first.
|
||||
|
||||
From Gutwenger-Mutzel (2001), p. 83:
|
||||
|
||||
For a tree edge e = v→w (w is a child of v):
|
||||
|
||||
- φ(e) = 3 * lowpt1[w] if lowpt2[w] < dfs_num[v]
|
||||
- φ(e) = 3 * lowpt1[w] + 2 if lowpt2[w] >= dfs_num[v]
|
||||
|
||||
For a frond e = v↪w (w is a proper ancestor of v):
|
||||
|
||||
- φ(e) = 3 * dfs_num[w] + 1
|
||||
|
||||
:param v: The vertex whose adjacency list is being sorted.
|
||||
:param eid: The edge ID to compute the key for.
|
||||
:param pt: The palm tree with DFS data.
|
||||
:param graph: The multigraph.
|
||||
:return: The integer sort key φ(e).
|
||||
"""
|
||||
e: Edge | None = graph.get_edge(eid)
|
||||
assert e is not None
|
||||
w: Hashable = e.other(v)
|
||||
w_num: int = pt.dfs_num.get(w, 0)
|
||||
v_num: int = pt.dfs_num.get(v, 0)
|
||||
|
||||
if eid in pt.tree_edges and pt.parent_edge.get(w) == eid:
|
||||
# Tree edge v→w where w is the child of v.
|
||||
if pt.lowpt2[w] < v_num:
|
||||
# Case 1: lowpt2(w) < dfs_num(v).
|
||||
return 3 * pt.lowpt1[w]
|
||||
# Case 3: lowpt2(w) >= dfs_num(v).
|
||||
return 3 * pt.lowpt1[w] + 2
|
||||
|
||||
if eid in pt.fronds and w_num < v_num:
|
||||
# Frond from v to proper ancestor w (w has smaller DFS number).
|
||||
# Case 2: phi = 3 * dfs_num(w) + 1.
|
||||
return 3 * w_num + 1
|
||||
|
||||
# Parent edge or edge to descendant (frond from the other direction):
|
||||
# assign a large key so it is sorted last.
|
||||
return 3 * (w_num + 1) * 3 + 3
|
||||
|
||||
|
||||
def sort_adjacency_lists(
|
||||
graph: MultiGraph,
|
||||
pt: PalmTree,
|
||||
) -> None:
|
||||
"""Re-order each vertex's adjacency list by φ values in place.
|
||||
|
||||
This must be called before running PathSearch (Algorithm 3) to
|
||||
ensure the DFS visits edges in the order required for the
|
||||
Gutwenger-Mutzel triconnected components algorithm.
|
||||
|
||||
:param graph: The multigraph whose adjacency lists will be sorted.
|
||||
:param pt: A fully computed palm tree for the graph.
|
||||
:return: None
|
||||
"""
|
||||
for v in graph.vertices:
|
||||
adj: list[int] = graph.adj_edge_ids(v)
|
||||
adj_sorted: list[int] = sorted(
|
||||
adj,
|
||||
key=lambda eid, _v=v: phi_key(_v, eid, pt, graph)
|
||||
)
|
||||
graph.set_adj_order(v, adj_sorted)
|
||||
340
src/spqrtree/_spqr.py
Normal file
340
src/spqrtree/_spqr.py
Normal file
@@ -0,0 +1,340 @@
|
||||
# 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.
|
||||
"""SPQR-Tree construction from triconnected split components.
|
||||
|
||||
Builds an SPQR-tree from the triconnected split components of a
|
||||
biconnected multigraph, following Di Battista & Tamassia (1996).
|
||||
|
||||
Each triconnected split component maps to an SPQR-tree node:
|
||||
- BOND with 2 total edges -> Q-node (degenerate: a single edge).
|
||||
- BOND with 3+ total edges -> P-node (parallel edges).
|
||||
- POLYGON -> S-node (simple cycle).
|
||||
- TRICONNECTED -> R-node (3-connected subgraph).
|
||||
|
||||
Adjacent components (sharing a virtual edge) are linked as
|
||||
parent-child pairs in the tree.
|
||||
|
||||
Public API::
|
||||
|
||||
from spqrtree._spqr import NodeType, SPQRNode, build_spqr_tree
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Hashable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from spqrtree._graph import MultiGraph
|
||||
from spqrtree._triconnected import (
|
||||
ComponentType,
|
||||
TriconnectedComponent,
|
||||
find_triconnected_components,
|
||||
)
|
||||
|
||||
|
||||
class NodeType(Enum):
|
||||
"""SPQR-tree node types.
|
||||
|
||||
:cvar Q: A Q-node represents a single edge (degenerate bond).
|
||||
:cvar S: An S-node represents a simple cycle (series).
|
||||
:cvar P: A P-node represents parallel edges (parallel).
|
||||
:cvar R: An R-node represents a 3-connected subgraph (rigid).
|
||||
"""
|
||||
|
||||
Q = "Q"
|
||||
"""Q-node: a single real edge."""
|
||||
|
||||
S = "S"
|
||||
"""S-node: a simple cycle (polygon)."""
|
||||
|
||||
P = "P"
|
||||
"""P-node: parallel edges (bond with 3+ real edges)."""
|
||||
|
||||
R = "R"
|
||||
"""R-node: a 3-connected subgraph."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SPQRNode:
|
||||
"""A node in the SPQR-tree.
|
||||
|
||||
:param type: The node type (Q, S, P, or R).
|
||||
:param skeleton: The skeleton graph for this node, containing
|
||||
the real and virtual edges of the component.
|
||||
:param poles: The pair of poles (u, v) for this node, i.e., the
|
||||
two vertices of the virtual edge connecting this node to its
|
||||
parent (or the two endpoints for the root).
|
||||
:param parent: The parent SPQRNode, or None if this is the root.
|
||||
:param children: Ordered list of child SPQRNodes.
|
||||
"""
|
||||
|
||||
type: NodeType
|
||||
"""The SPQR-tree node type."""
|
||||
|
||||
skeleton: MultiGraph
|
||||
"""The skeleton graph of this node."""
|
||||
|
||||
poles: tuple[Hashable, Hashable]
|
||||
"""The two pole vertices of this node."""
|
||||
|
||||
parent: SPQRNode | None
|
||||
"""Parent node, or None if this is the root."""
|
||||
|
||||
children: list[SPQRNode] = field(default_factory=list)
|
||||
"""Child nodes in the SPQR-tree."""
|
||||
|
||||
|
||||
def build_spqr_tree(graph: MultiGraph) -> SPQRNode:
|
||||
"""Build an SPQR-tree for a biconnected multigraph.
|
||||
|
||||
Decomposes the graph into triconnected split components, then
|
||||
assembles them into an SPQR-tree. Each component becomes a node
|
||||
with type Q (degenerate bond of 2 edges), P (bond of 3+ edges),
|
||||
S (polygon), or R (triconnected).
|
||||
|
||||
:param graph: A biconnected multigraph.
|
||||
:return: The root SPQRNode of the SPQR-tree.
|
||||
"""
|
||||
comps: list[TriconnectedComponent] = find_triconnected_components(
|
||||
graph)
|
||||
|
||||
if not comps:
|
||||
# Degenerate: single edge graph.
|
||||
skel: MultiGraph = MultiGraph()
|
||||
for e in graph.edges:
|
||||
skel.add_edge(e.u, e.v, virtual=e.virtual)
|
||||
verts: list[Hashable] = list(graph.vertices)
|
||||
poles: tuple[Hashable, Hashable] = (
|
||||
verts[0],
|
||||
verts[1] if len(verts) > 1 else verts[0])
|
||||
return SPQRNode(
|
||||
type=NodeType.Q,
|
||||
skeleton=skel,
|
||||
poles=poles,
|
||||
parent=None,
|
||||
)
|
||||
|
||||
if len(comps) == 1:
|
||||
return _make_single_node(comps[0], None)
|
||||
|
||||
# Multiple components: build tree from adjacency.
|
||||
return _build_tree_from_components(comps)
|
||||
|
||||
|
||||
def _comp_to_node_type(comp: TriconnectedComponent) -> NodeType:
|
||||
"""Convert a TriconnectedComponent type to an SPQRNode NodeType.
|
||||
|
||||
A BOND with exactly 2 total edges (one real + one virtual) is a
|
||||
Q-node (degenerate: a single edge). A BOND with 3 or more total
|
||||
edges is a P-node (parallel class).
|
||||
|
||||
:param comp: The triconnected component.
|
||||
:return: The corresponding NodeType.
|
||||
"""
|
||||
if comp.type == ComponentType.BOND:
|
||||
if len(comp.edges) <= 2:
|
||||
return NodeType.Q
|
||||
return NodeType.P
|
||||
if comp.type == ComponentType.POLYGON:
|
||||
return NodeType.S
|
||||
return NodeType.R
|
||||
|
||||
|
||||
def _make_skeleton(comp: TriconnectedComponent) -> MultiGraph:
|
||||
"""Build a skeleton MultiGraph for a triconnected component.
|
||||
|
||||
:param comp: The triconnected component.
|
||||
:return: A MultiGraph containing all edges of the component.
|
||||
"""
|
||||
skel: MultiGraph = MultiGraph()
|
||||
for e in comp.edges:
|
||||
skel.add_edge(e.u, e.v, virtual=e.virtual)
|
||||
return skel
|
||||
|
||||
|
||||
def _get_poles(
|
||||
comp: TriconnectedComponent,
|
||||
) -> tuple[Hashable, Hashable]:
|
||||
"""Determine the poles of a component.
|
||||
|
||||
The poles are the two endpoints of the virtual edge in the
|
||||
component (if any), or the two endpoints of the first real edge.
|
||||
|
||||
:param comp: The triconnected component.
|
||||
:return: A (u, v) pair of pole vertices.
|
||||
:raises RuntimeError: If the component has no edges.
|
||||
"""
|
||||
for e in comp.edges:
|
||||
if e.virtual:
|
||||
return e.u, e.v
|
||||
# No virtual edge: use first edge endpoints.
|
||||
if comp.edges:
|
||||
return comp.edges[0].u, comp.edges[0].v
|
||||
raise RuntimeError("Component has no edges")
|
||||
|
||||
|
||||
def _make_single_node(
|
||||
comp: TriconnectedComponent,
|
||||
parent_node: SPQRNode | None,
|
||||
) -> SPQRNode:
|
||||
"""Create a single SPQRNode from one TriconnectedComponent.
|
||||
|
||||
:param comp: The triconnected component.
|
||||
:param parent_node: The parent SPQRNode, or None for root.
|
||||
:return: A new SPQRNode.
|
||||
"""
|
||||
ntype: NodeType = _comp_to_node_type(comp)
|
||||
skel: MultiGraph = _make_skeleton(comp)
|
||||
poles: tuple[Hashable, Hashable] = _get_poles(comp)
|
||||
node: SPQRNode = SPQRNode(
|
||||
type=ntype,
|
||||
skeleton=skel,
|
||||
poles=poles,
|
||||
parent=parent_node,
|
||||
)
|
||||
return node
|
||||
|
||||
|
||||
def _collect_ve_to_comps(
|
||||
comps: list[TriconnectedComponent],
|
||||
) -> dict[int, list[int]]:
|
||||
"""Map virtual edge IDs to component indices containing them.
|
||||
|
||||
Scans all components for virtual edges, then builds a mapping
|
||||
from each virtual edge ID to the list of component indices
|
||||
that include that edge.
|
||||
|
||||
:param comps: List of triconnected components.
|
||||
:return: Mapping from virtual edge ID to component indices.
|
||||
"""
|
||||
virtual_edge_ids: set[int] = set()
|
||||
for comp in comps:
|
||||
for e in comp.edges:
|
||||
if e.virtual:
|
||||
virtual_edge_ids.add(e.id)
|
||||
|
||||
ve_to_comps: dict[int, list[int]] = {}
|
||||
for i, comp in enumerate(comps):
|
||||
for e in comp.edges:
|
||||
if e.id in virtual_edge_ids:
|
||||
ve_to_comps.setdefault(e.id, []).append(i)
|
||||
return ve_to_comps
|
||||
|
||||
|
||||
def _build_adj_and_root(
|
||||
comps: list[TriconnectedComponent],
|
||||
ve_to_comps: dict[int, list[int]],
|
||||
) -> tuple[dict[int, list[tuple[int, int]]], int]:
|
||||
"""Build component adjacency and choose the root component.
|
||||
|
||||
Two components are adjacent if they share a virtual edge. The
|
||||
root is the component with the most virtual-edge adjacencies
|
||||
(the most central node in the tree).
|
||||
|
||||
:param comps: List of triconnected components.
|
||||
:param ve_to_comps: Virtual edge to component indices mapping.
|
||||
:return: A (adj, root_ci) tuple where adj maps component index
|
||||
to list of (neighbor_index, shared_ve_id) pairs, and
|
||||
root_ci is the chosen root component index.
|
||||
"""
|
||||
adj: dict[int, list[tuple[int, int]]] = {
|
||||
i: [] for i in range(len(comps))
|
||||
}
|
||||
for veid, idxs in ve_to_comps.items():
|
||||
if len(idxs) == 2:
|
||||
i, j = idxs
|
||||
adj[i].append((j, veid))
|
||||
adj[j].append((i, veid))
|
||||
|
||||
adj_count: list[int] = [0] * len(comps)
|
||||
for idxs in ve_to_comps.values():
|
||||
if len(idxs) == 2:
|
||||
adj_count[idxs[0]] += 1
|
||||
adj_count[idxs[1]] += 1
|
||||
root_ci = max(range(len(comps)), key=lambda i: adj_count[i])
|
||||
return adj, root_ci
|
||||
|
||||
|
||||
def _build_tree_from_components(
|
||||
comps: list[TriconnectedComponent],
|
||||
) -> SPQRNode:
|
||||
"""Build an SPQR-tree from a list of triconnected components.
|
||||
|
||||
Components sharing a virtual edge are adjacent in the tree.
|
||||
The component with the most virtual-edge adjacencies is the root.
|
||||
|
||||
:param comps: List of triconnected components.
|
||||
:return: The root SPQRNode.
|
||||
"""
|
||||
ve_to_comps: dict[int, list[int]] = _collect_ve_to_comps(comps)
|
||||
adj: dict[int, list[tuple[int, int]]]
|
||||
root_ci: int
|
||||
adj, root_ci = _build_adj_and_root(
|
||||
comps, ve_to_comps)
|
||||
|
||||
# Build the tree by BFS from the chosen root component.
|
||||
nodes: list[SPQRNode | None] = [None] * len(comps)
|
||||
visited: set[int] = set()
|
||||
bfs_queue: deque[tuple[int, SPQRNode | None]] = deque([
|
||||
(root_ci, None)])
|
||||
|
||||
while bfs_queue:
|
||||
ci, parent_node = bfs_queue.popleft()
|
||||
if ci in visited:
|
||||
continue
|
||||
visited.add(ci)
|
||||
|
||||
comp: TriconnectedComponent = comps[ci]
|
||||
ntype: NodeType = _comp_to_node_type(comp)
|
||||
skel: MultiGraph = _make_skeleton(comp)
|
||||
poles: tuple[Hashable, Hashable] = _get_poles(comp)
|
||||
node: SPQRNode = SPQRNode(
|
||||
type=ntype,
|
||||
skeleton=skel,
|
||||
poles=poles,
|
||||
parent=parent_node,
|
||||
)
|
||||
if parent_node is not None:
|
||||
parent_node.children.append(node)
|
||||
nodes[ci] = node
|
||||
|
||||
for cj, _ in adj[ci]:
|
||||
if cj not in visited:
|
||||
bfs_queue.append((cj, node))
|
||||
|
||||
# Handle any disconnected components (shouldn't happen for
|
||||
# biconnected graphs, but be defensive).
|
||||
for i in range(len(comps)):
|
||||
if nodes[i] is None:
|
||||
comp = comps[i]
|
||||
ntype = _comp_to_node_type(comp)
|
||||
skel = _make_skeleton(comp)
|
||||
poles = _get_poles(comp)
|
||||
nodes[i] = SPQRNode(
|
||||
type=ntype,
|
||||
skeleton=skel,
|
||||
poles=poles,
|
||||
parent=None,
|
||||
)
|
||||
|
||||
# Return the chosen root node.
|
||||
root: SPQRNode | None = nodes[root_ci]
|
||||
assert root is not None
|
||||
return root
|
||||
1495
src/spqrtree/_triconnected.py
Normal file
1495
src/spqrtree/_triconnected.py
Normal file
File diff suppressed because it is too large
Load Diff
0
src/spqrtree/py.typed
Normal file
0
src/spqrtree/py.typed
Normal file
228
tests/test_graph.py
Normal file
228
tests/test_graph.py
Normal file
@@ -0,0 +1,228 @@
|
||||
# Pure Python SPQR-Tree implementation.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/1
|
||||
# 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.
|
||||
"""Tests for the MultiGraph data structure (_graph.py)."""
|
||||
import unittest
|
||||
from collections.abc import Hashable
|
||||
|
||||
from spqrtree._graph import Edge, MultiGraph
|
||||
|
||||
|
||||
class TestEdge(unittest.TestCase):
|
||||
"""Tests for the Edge dataclass."""
|
||||
|
||||
def test_edge_creation(self) -> None:
|
||||
"""Test basic edge creation with required attributes."""
|
||||
e: Edge = Edge(id=0, u=1, v=2)
|
||||
self.assertEqual(e.id, 0)
|
||||
self.assertEqual(e.u, 1)
|
||||
self.assertEqual(e.v, 2)
|
||||
self.assertFalse(e.virtual)
|
||||
|
||||
def test_edge_virtual(self) -> None:
|
||||
"""Test creating a virtual edge."""
|
||||
e: Edge = Edge(id=1, u=3, v=4, virtual=True)
|
||||
self.assertTrue(e.virtual)
|
||||
|
||||
def test_edge_endpoints(self) -> None:
|
||||
"""Test that endpoints method returns both endpoints."""
|
||||
e: Edge = Edge(id=0, u=1, v=2)
|
||||
self.assertEqual(e.endpoints(), (1, 2))
|
||||
|
||||
def test_edge_other(self) -> None:
|
||||
"""Test that other() returns the opposite endpoint."""
|
||||
e: Edge = Edge(id=0, u=1, v=2)
|
||||
self.assertEqual(e.other(1), 2)
|
||||
self.assertEqual(e.other(2), 1)
|
||||
|
||||
|
||||
class TestMultiGraphVertices(unittest.TestCase):
|
||||
"""Tests for vertex operations on MultiGraph."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up a fresh MultiGraph for each test."""
|
||||
self.g: MultiGraph = MultiGraph()
|
||||
"""The graph under test."""
|
||||
|
||||
def test_add_vertex(self) -> None:
|
||||
"""Test adding a single vertex."""
|
||||
self.g.add_vertex(1)
|
||||
self.assertIn(1, self.g.vertices)
|
||||
|
||||
def test_add_multiple_vertices(self) -> None:
|
||||
"""Test adding multiple vertices."""
|
||||
for v in [1, 2, 3, 4]:
|
||||
self.g.add_vertex(v)
|
||||
self.assertEqual(set(self.g.vertices), {1, 2, 3, 4})
|
||||
|
||||
def test_add_duplicate_vertex(self) -> None:
|
||||
"""Test that adding a duplicate vertex is a no-op."""
|
||||
self.g.add_vertex(1)
|
||||
self.g.add_vertex(1)
|
||||
self.assertEqual(len(self.g.vertices), 1)
|
||||
|
||||
def test_remove_vertex(self) -> None:
|
||||
"""Test removing a vertex also removes its edges."""
|
||||
self.g.add_vertex(1)
|
||||
self.g.add_vertex(2)
|
||||
self.g.add_edge(1, 2)
|
||||
self.g.remove_vertex(1)
|
||||
self.assertNotIn(1, self.g.vertices)
|
||||
self.assertEqual(len(self.g.edges), 0)
|
||||
|
||||
def test_vertex_count(self) -> None:
|
||||
"""Test vertex count after additions."""
|
||||
for v in range(5):
|
||||
self.g.add_vertex(v)
|
||||
self.assertEqual(self.g.num_vertices(), 5)
|
||||
|
||||
|
||||
class TestMultiGraphEdges(unittest.TestCase):
|
||||
"""Tests for edge operations on MultiGraph."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up a fresh MultiGraph with two vertices."""
|
||||
self.g: MultiGraph = MultiGraph()
|
||||
"""The graph under test."""
|
||||
self.g.add_vertex(1)
|
||||
self.g.add_vertex(2)
|
||||
self.g.add_vertex(3)
|
||||
|
||||
def test_add_edge(self) -> None:
|
||||
"""Test adding an edge between two vertices."""
|
||||
e: Edge = self.g.add_edge(1, 2)
|
||||
self.assertIn(e.id, {e2.id for e2 in self.g.edges})
|
||||
self.assertEqual(e.u, 1)
|
||||
self.assertEqual(e.v, 2)
|
||||
|
||||
def test_add_edge_returns_edge(self) -> None:
|
||||
"""Test that add_edge returns an Edge object."""
|
||||
e: Edge = self.g.add_edge(1, 2)
|
||||
self.assertIsInstance(e, Edge)
|
||||
|
||||
def test_add_parallel_edges(self) -> None:
|
||||
"""Test adding parallel edges between the same pair."""
|
||||
e1: Edge = self.g.add_edge(1, 2)
|
||||
e2: Edge = self.g.add_edge(1, 2)
|
||||
self.assertNotEqual(e1.id, e2.id)
|
||||
self.assertEqual(len(self.g.edges_between(1, 2)), 2)
|
||||
|
||||
def test_remove_edge(self) -> None:
|
||||
"""Test removing a specific edge by ID."""
|
||||
e: Edge = self.g.add_edge(1, 2)
|
||||
self.g.remove_edge(e.id)
|
||||
self.assertEqual(
|
||||
len(self.g.edges_between(1, 2)), 0)
|
||||
|
||||
def test_remove_one_parallel_edge(self) -> None:
|
||||
"""Test removing one of several parallel edges."""
|
||||
e1: Edge = self.g.add_edge(1, 2)
|
||||
e2: Edge = self.g.add_edge(1, 2)
|
||||
self.g.remove_edge(e1.id)
|
||||
remaining: list[Edge] = (
|
||||
self.g.edges_between(1, 2))
|
||||
self.assertEqual(len(remaining), 1)
|
||||
self.assertEqual(remaining[0].id, e2.id)
|
||||
|
||||
def test_edge_count(self) -> None:
|
||||
"""Test total edge count."""
|
||||
self.g.add_edge(1, 2)
|
||||
self.g.add_edge(2, 3)
|
||||
self.g.add_edge(1, 2)
|
||||
self.assertEqual(self.g.num_edges(), 3)
|
||||
|
||||
def test_add_virtual_edge(self) -> None:
|
||||
"""Test adding a virtual edge."""
|
||||
e: Edge = self.g.add_edge(1, 2, virtual=True)
|
||||
self.assertTrue(e.virtual)
|
||||
|
||||
def test_edges_property(self) -> None:
|
||||
"""Test that edges property returns all edges."""
|
||||
e1: Edge = self.g.add_edge(1, 2)
|
||||
e2: Edge = self.g.add_edge(2, 3)
|
||||
ids: set[int] = {e.id for e in self.g.edges}
|
||||
self.assertIn(e1.id, ids)
|
||||
self.assertIn(e2.id, ids)
|
||||
|
||||
|
||||
class TestMultiGraphNeighbors(unittest.TestCase):
|
||||
"""Tests for neighbor/adjacency operations on MultiGraph."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up a triangle graph (K3)."""
|
||||
self.g: MultiGraph = MultiGraph()
|
||||
"""The graph under test."""
|
||||
for v in [1, 2, 3]:
|
||||
self.g.add_vertex(v)
|
||||
self.g.add_edge(1, 2)
|
||||
self.g.add_edge(2, 3)
|
||||
self.g.add_edge(1, 3)
|
||||
|
||||
def test_neighbors(self) -> None:
|
||||
"""Test neighbors returns all adjacent vertices."""
|
||||
nbrs: list[Hashable] = self.g.neighbors(1)
|
||||
self.assertEqual(set(nbrs), {2, 3})
|
||||
|
||||
def test_neighbors_with_parallel_edges(self) -> None:
|
||||
"""Test neighbors returns unique vertices with parallel edges."""
|
||||
self.g.add_edge(1, 2)
|
||||
nbrs: list[Hashable] = self.g.neighbors(1)
|
||||
self.assertEqual(set(nbrs), {2, 3})
|
||||
|
||||
def test_incident_edges(self) -> None:
|
||||
"""Test incident_edges returns edges incident to a vertex."""
|
||||
edges: list[Edge] = self.g.incident_edges(1)
|
||||
self.assertEqual(len(edges), 2)
|
||||
|
||||
def test_edges_between(self) -> None:
|
||||
"""Test edges_between returns edges between two vertices."""
|
||||
edges: list[Edge] = self.g.edges_between(1, 2)
|
||||
self.assertEqual(len(edges), 1)
|
||||
self.assertEqual(edges[0].u, 1)
|
||||
self.assertEqual(edges[0].v, 2)
|
||||
|
||||
def test_degree(self) -> None:
|
||||
"""Test degree counts incident edges (with multiplicity)."""
|
||||
self.assertEqual(self.g.degree(1), 2)
|
||||
self.g.add_edge(1, 2)
|
||||
self.assertEqual(self.g.degree(1), 3)
|
||||
|
||||
|
||||
class TestMultiGraphCopy(unittest.TestCase):
|
||||
"""Tests for copying operations on MultiGraph."""
|
||||
|
||||
def test_copy_is_independent(self) -> None:
|
||||
"""Test that a copy is independent from the original."""
|
||||
g: MultiGraph = MultiGraph()
|
||||
g.add_vertex(1)
|
||||
g.add_vertex(2)
|
||||
g.add_edge(1, 2)
|
||||
g2: MultiGraph = g.copy()
|
||||
g2.add_vertex(3)
|
||||
self.assertNotIn(3, g.vertices)
|
||||
|
||||
def test_copy_has_same_structure(self) -> None:
|
||||
"""Test that a copy has the same vertices and edges."""
|
||||
g: MultiGraph = MultiGraph()
|
||||
for v in [1, 2, 3]:
|
||||
g.add_vertex(v)
|
||||
g.add_edge(1, 2)
|
||||
g.add_edge(2, 3)
|
||||
g2: MultiGraph = g.copy()
|
||||
self.assertEqual(set(g2.vertices), {1, 2, 3})
|
||||
self.assertEqual(g2.num_edges(), 2)
|
||||
471
tests/test_palm_tree.py
Normal file
471
tests/test_palm_tree.py
Normal file
@@ -0,0 +1,471 @@
|
||||
# Pure Python SPQR-Tree implementation.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/1
|
||||
# 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.
|
||||
"""Tests for the palm tree construction (_palm_tree.py).
|
||||
|
||||
All DFS results are based on insertion-order adjacency traversal and
|
||||
DFS from vertex 1. Edge insertion order is specified in each test.
|
||||
"""
|
||||
import unittest
|
||||
from collections.abc import Hashable
|
||||
|
||||
from spqrtree._graph import Edge, MultiGraph
|
||||
from spqrtree._palm_tree import PalmTree, build_palm_tree, phi_key
|
||||
|
||||
|
||||
def _make_k3() -> tuple[MultiGraph, list[int]]:
|
||||
"""Build the triangle graph K3 (vertices 1,2,3; edges 1-2, 2-3, 1-3).
|
||||
|
||||
:return: A tuple (graph, edge_ids) where edge_ids is
|
||||
[e0.id, e1.id, e2.id].
|
||||
"""
|
||||
g: MultiGraph = MultiGraph()
|
||||
for v in [1, 2, 3]:
|
||||
g.add_vertex(v)
|
||||
e0: Edge = g.add_edge(1, 2)
|
||||
e1: Edge = g.add_edge(2, 3)
|
||||
e2: Edge = g.add_edge(1, 3)
|
||||
return g, [e0.id, e1.id, e2.id]
|
||||
|
||||
|
||||
def _make_p3() -> tuple[MultiGraph, list[int]]:
|
||||
"""Build the path graph P3 (vertices 1,2,3; edges 1-2, 2-3).
|
||||
|
||||
:return: A tuple (graph, edge_ids) where edge_ids is [e0.id, e1.id].
|
||||
"""
|
||||
g: MultiGraph = MultiGraph()
|
||||
for v in [1, 2, 3]:
|
||||
g.add_vertex(v)
|
||||
e0: Edge = g.add_edge(1, 2)
|
||||
e1: Edge = g.add_edge(2, 3)
|
||||
return g, [e0.id, e1.id]
|
||||
|
||||
|
||||
def _make_c4() -> tuple[MultiGraph, list[int]]:
|
||||
"""Build the 4-cycle C4 (vertices 1,2,3,4; edges 1-2,2-3,3-4,4-1).
|
||||
|
||||
:return: A tuple (graph, edge_ids) in insertion order.
|
||||
"""
|
||||
g: MultiGraph = MultiGraph()
|
||||
for v in [1, 2, 3, 4]:
|
||||
g.add_vertex(v)
|
||||
e0: Edge = g.add_edge(1, 2)
|
||||
e1: Edge = g.add_edge(2, 3)
|
||||
e2: Edge = g.add_edge(3, 4)
|
||||
e3: Edge = g.add_edge(4, 1)
|
||||
return g, [e0.id, e1.id, e2.id, e3.id]
|
||||
|
||||
|
||||
class TestPalmTreeType(unittest.TestCase):
|
||||
"""Tests that build_palm_tree returns a PalmTree instance."""
|
||||
|
||||
def test_returns_palm_tree(self) -> None:
|
||||
"""Test that build_palm_tree returns a PalmTree object."""
|
||||
g: MultiGraph
|
||||
g, _ = _make_k3()
|
||||
pt: PalmTree = build_palm_tree(g, 1)
|
||||
self.assertIsInstance(pt, PalmTree)
|
||||
|
||||
|
||||
class TestPalmTreePath(unittest.TestCase):
|
||||
"""Tests for palm tree on a path graph P3 (no back edges)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Build palm tree for P3 starting at vertex 1."""
|
||||
g: MultiGraph
|
||||
g, eids = _make_p3()
|
||||
self.eids: list[int] = eids
|
||||
"""The edge IDs of the P3 graph."""
|
||||
self.pt: PalmTree = build_palm_tree(g, 1)
|
||||
"""The palm tree for the graph."""
|
||||
|
||||
def test_dfs_num_root(self) -> None:
|
||||
"""Test that the start vertex has DFS number 1."""
|
||||
self.assertEqual(self.pt.dfs_num[1], 1)
|
||||
|
||||
def test_dfs_num_order(self) -> None:
|
||||
"""Test that DFS numbers are assigned 1, 2, 3 in traversal order."""
|
||||
nums: list[int] = sorted(self.pt.dfs_num.values())
|
||||
self.assertEqual(nums, [1, 2, 3])
|
||||
|
||||
def test_tree_edges_count(self) -> None:
|
||||
"""Test that there are n-1 = 2 tree edges."""
|
||||
self.assertEqual(len(self.pt.tree_edges), 2)
|
||||
|
||||
def test_no_fronds(self) -> None:
|
||||
"""Test that there are no fronds on a tree path."""
|
||||
self.assertEqual(len(self.pt.fronds), 0)
|
||||
|
||||
def test_all_edges_are_tree_edges(self) -> None:
|
||||
"""Test that both edges are classified as tree edges."""
|
||||
e0, e1 = self.eids
|
||||
self.assertIn(e0, self.pt.tree_edges)
|
||||
self.assertIn(e1, self.pt.tree_edges)
|
||||
|
||||
def test_parent_root(self) -> None:
|
||||
"""Test that the root has no parent (None)."""
|
||||
self.assertIsNone(self.pt.parent.get(1))
|
||||
|
||||
def test_nd_values(self) -> None:
|
||||
"""Test that ND values are correct for P3."""
|
||||
# DFS from 1: 1→2→3 (since 2 is first in adj[1])
|
||||
self.assertEqual(self.pt.nd[1], 3)
|
||||
|
||||
def test_nd_leaf(self) -> None:
|
||||
"""Test that a leaf vertex has ND = 1."""
|
||||
# Vertex 3 is a leaf in P3
|
||||
self.assertEqual(self.pt.nd[3], 1)
|
||||
|
||||
def test_lowpt1_values(self) -> None:
|
||||
"""Test lowpt1 values for P3 (all vertices reach only themselves)."""
|
||||
for v in [1, 2, 3]:
|
||||
self.assertLessEqual(
|
||||
self.pt.lowpt1[v], self.pt.dfs_num[v]
|
||||
)
|
||||
|
||||
def test_lowpt1_no_fronds(self) -> None:
|
||||
"""Test that lowpt1[v] == dfs_num[v] when no fronds exist."""
|
||||
for v in [1, 2, 3]:
|
||||
self.assertEqual(self.pt.lowpt1[v], self.pt.dfs_num[v])
|
||||
|
||||
|
||||
class TestPalmTreeTriangle(unittest.TestCase):
|
||||
"""Tests for palm tree on the triangle graph K3."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Build palm tree for K3 starting at vertex 1."""
|
||||
g: MultiGraph
|
||||
g, eids = _make_k3()
|
||||
self.eids: list[int] = eids
|
||||
"""The edge IDs of the K3 graph."""
|
||||
self.pt: PalmTree = build_palm_tree(g, 1)
|
||||
"""The palm tree for the graph."""
|
||||
|
||||
def test_dfs_num_root(self) -> None:
|
||||
"""Test that vertex 1 has DFS number 1."""
|
||||
self.assertEqual(self.pt.dfs_num[1], 1)
|
||||
|
||||
def test_dfs_num_all_assigned(self) -> None:
|
||||
"""Test that all vertices get a DFS number."""
|
||||
self.assertEqual(set(self.pt.dfs_num.keys()), {1, 2, 3})
|
||||
|
||||
def test_tree_edge_count(self) -> None:
|
||||
"""Test that there are n-1 = 2 tree edges."""
|
||||
self.assertEqual(len(self.pt.tree_edges), 2)
|
||||
|
||||
def test_frond_count(self) -> None:
|
||||
"""Test that there is exactly 1 frond."""
|
||||
self.assertEqual(len(self.pt.fronds), 1)
|
||||
|
||||
def test_frond_is_back_edge(self) -> None:
|
||||
"""Test that the frond e2 (1-3) is classified as a frond."""
|
||||
# e2 = edges[2] = the third edge added (1-3)
|
||||
e0, e1, e2 = self.eids
|
||||
self.assertIn(e2, self.pt.fronds)
|
||||
self.assertIn(e0, self.pt.tree_edges)
|
||||
self.assertIn(e1, self.pt.tree_edges)
|
||||
|
||||
def test_lowpt1_all_reach_root(self) -> None:
|
||||
"""Test that all vertices have lowpt1 = 1 via frond."""
|
||||
for v in [1, 2, 3]:
|
||||
self.assertEqual(self.pt.lowpt1[v], 1)
|
||||
|
||||
def test_nd_root(self) -> None:
|
||||
"""Test that the root has nd = 3."""
|
||||
self.assertEqual(self.pt.nd[1], 3)
|
||||
|
||||
def test_nd_leaf(self) -> None:
|
||||
"""Test that the DFS leaf (vertex 3) has nd = 1."""
|
||||
# Vertex 3 is visited last in K3 with DFS from 1
|
||||
leaf: Hashable = next(
|
||||
v for v, n in self.pt.nd.items() if n == 1
|
||||
)
|
||||
self.assertEqual(self.pt.nd[leaf], 1)
|
||||
|
||||
def test_first_child_of_root(self) -> None:
|
||||
"""Test that root vertex 1 has a first child."""
|
||||
self.assertIsNotNone(self.pt.first_child.get(1))
|
||||
|
||||
def test_first_child_of_leaf(self) -> None:
|
||||
"""Test that the DFS leaf has no first child."""
|
||||
leaf: Hashable = next(
|
||||
v for v, n in self.pt.nd.items() if n == 1
|
||||
)
|
||||
self.assertIsNone(self.pt.first_child.get(leaf))
|
||||
|
||||
def test_lowpt1_le_dfs_num(self) -> None:
|
||||
"""Test that lowpt1[v] <= dfs_num[v] for all v."""
|
||||
for v in [1, 2, 3]:
|
||||
self.assertLessEqual(self.pt.lowpt1[v], self.pt.dfs_num[v])
|
||||
|
||||
def test_lowpt1_le_lowpt2(self) -> None:
|
||||
"""Test that lowpt1[v] <= lowpt2[v] for all v."""
|
||||
for v in [1, 2, 3]:
|
||||
self.assertLessEqual(self.pt.lowpt1[v], self.pt.lowpt2[v])
|
||||
|
||||
|
||||
class TestPalmTreeC4(unittest.TestCase):
|
||||
"""Tests for palm tree on the 4-cycle C4."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Build palm tree for C4 starting at vertex 1."""
|
||||
g: MultiGraph
|
||||
g, eids = _make_c4()
|
||||
self.eids: list[int] = eids
|
||||
"""The edge IDs of the C4 graph."""
|
||||
self.pt: PalmTree = build_palm_tree(g, 1)
|
||||
"""The palm tree for the graph."""
|
||||
|
||||
def test_dfs_num_root(self) -> None:
|
||||
"""Test that vertex 1 has DFS number 1."""
|
||||
self.assertEqual(self.pt.dfs_num[1], 1)
|
||||
|
||||
def test_dfs_num_all_assigned(self) -> None:
|
||||
"""Test that all 4 vertices get DFS numbers."""
|
||||
self.assertEqual(set(self.pt.dfs_num.keys()), {1, 2, 3, 4})
|
||||
|
||||
def test_tree_edge_count(self) -> None:
|
||||
"""Test that there are n-1 = 3 tree edges."""
|
||||
self.assertEqual(len(self.pt.tree_edges), 3)
|
||||
|
||||
def test_frond_count(self) -> None:
|
||||
"""Test that there is exactly 1 frond."""
|
||||
self.assertEqual(len(self.pt.fronds), 1)
|
||||
|
||||
def test_frond_classification(self) -> None:
|
||||
"""Test that e3 (4-1) is a frond and e0,e1,e2 are tree edges."""
|
||||
e0, e1, e2, e3 = self.eids
|
||||
self.assertIn(e0, self.pt.tree_edges)
|
||||
self.assertIn(e1, self.pt.tree_edges)
|
||||
self.assertIn(e2, self.pt.tree_edges)
|
||||
self.assertIn(e3, self.pt.fronds)
|
||||
|
||||
def test_nd_root(self) -> None:
|
||||
"""Test that root vertex 1 has nd = 4."""
|
||||
self.assertEqual(self.pt.nd[1], 4)
|
||||
|
||||
def test_nd_leaf(self) -> None:
|
||||
"""Test that the DFS leaf has nd = 1."""
|
||||
leaf: Hashable = next(
|
||||
v for v, n in self.pt.nd.items() if n == 1
|
||||
)
|
||||
self.assertEqual(self.pt.nd[leaf], 1)
|
||||
|
||||
def test_lowpt1_all_reach_root(self) -> None:
|
||||
"""Test that all vertices have lowpt1 = 1 due to the frond."""
|
||||
for v in [1, 2, 3, 4]:
|
||||
self.assertEqual(self.pt.lowpt1[v], 1)
|
||||
|
||||
def test_lowpt2_intermediate(self) -> None:
|
||||
"""Test that lowpt2 values are consistent (lowpt1 <= lowpt2)."""
|
||||
for v in [1, 2, 3, 4]:
|
||||
self.assertLessEqual(self.pt.lowpt1[v], self.pt.lowpt2[v])
|
||||
|
||||
def test_parent_structure(self) -> None:
|
||||
"""Test that the parent structure is a valid tree rooted at 1."""
|
||||
# Root has no parent
|
||||
self.assertIsNone(self.pt.parent.get(1))
|
||||
# All other vertices have a parent
|
||||
for v in [2, 3, 4]:
|
||||
self.assertIsNotNone(self.pt.parent.get(v))
|
||||
|
||||
|
||||
class TestPalmTreeLowptSpecific(unittest.TestCase):
|
||||
"""Tests for specific lowpt1/lowpt2 values on P3."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Build palm tree for P3 (path 1-2-3) starting at 1."""
|
||||
g: MultiGraph
|
||||
g, _ = _make_p3()
|
||||
self.pt: PalmTree = build_palm_tree(g, 1)
|
||||
"""The palm tree for the graph."""
|
||||
|
||||
def test_exact_dfs_nums(self) -> None:
|
||||
"""Test exact DFS numbers for the path graph."""
|
||||
self.assertEqual(self.pt.dfs_num[1], 1)
|
||||
self.assertEqual(self.pt.dfs_num[2], 2)
|
||||
self.assertEqual(self.pt.dfs_num[3], 3)
|
||||
|
||||
def test_exact_lowpt1(self) -> None:
|
||||
"""Test exact lowpt1 values for P3 (no back edges)."""
|
||||
# Without fronds, lowpt1[v] = dfs_num[v]
|
||||
self.assertEqual(self.pt.lowpt1[3], 3)
|
||||
self.assertEqual(self.pt.lowpt1[2], 2)
|
||||
self.assertEqual(self.pt.lowpt1[1], 1)
|
||||
|
||||
def test_exact_nd(self) -> None:
|
||||
"""Test exact nd values for P3."""
|
||||
self.assertEqual(self.pt.nd[3], 1)
|
||||
self.assertEqual(self.pt.nd[2], 2)
|
||||
self.assertEqual(self.pt.nd[1], 3)
|
||||
|
||||
def test_exact_parent(self) -> None:
|
||||
"""Test exact parent mapping for P3."""
|
||||
self.assertIsNone(self.pt.parent.get(1))
|
||||
self.assertEqual(self.pt.parent[2], 1)
|
||||
self.assertEqual(self.pt.parent[3], 2)
|
||||
|
||||
def test_exact_first_child(self) -> None:
|
||||
"""Test exact first_child mapping for P3."""
|
||||
self.assertEqual(self.pt.first_child[1], 2)
|
||||
self.assertEqual(self.pt.first_child[2], 3)
|
||||
self.assertIsNone(self.pt.first_child.get(3))
|
||||
|
||||
|
||||
class TestPhiKeyP3(unittest.TestCase):
|
||||
"""Tests for phi_key correctness on the path graph P3.
|
||||
|
||||
P3: vertices 1,2,3; edges 1-2 (tree), 2-3 (tree).
|
||||
DFS from 1: dfs_num = {1:1, 2:2, 3:3}.
|
||||
No fronds; lowpt1[v] = dfs_num[v] for all v.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Build palm tree for P3 and gather data for phi_key tests."""
|
||||
g: MultiGraph
|
||||
g, eids = _make_p3()
|
||||
self.g: MultiGraph = g
|
||||
"""The P3 graph under test."""
|
||||
self.eids: list[int] = eids
|
||||
"""The edge IDs of the P3 graph."""
|
||||
self.pt: PalmTree = build_palm_tree(g, 1)
|
||||
"""The palm tree for the graph."""
|
||||
|
||||
def test_tree_edge_1_2_case3_formula(self) -> None:
|
||||
"""Test phi_key for tree edge 1-2 uses case 3 formula.
|
||||
|
||||
For tree edge v=1, w=2:
|
||||
lowpt1[2]=2, lowpt2[2]=INF, dfs_num[1]=1.
|
||||
lowpt2[2] >= dfs_num[1] -> case 3 -> phi = 3*lowpt1[2]+2 = 8.
|
||||
"""
|
||||
e0: int = self.eids[0]
|
||||
key: int = phi_key(
|
||||
v=1, eid=e0, pt=self.pt, graph=self.g,
|
||||
)
|
||||
# lowpt1[2]=2, case 3: 3*2+2 = 8
|
||||
self.assertEqual(key, 3 * 2 + 2)
|
||||
|
||||
def test_tree_edge_2_3_case3_formula(self) -> None:
|
||||
"""Test phi_key for tree edge 2-3 uses case 3 formula.
|
||||
|
||||
For tree edge v=2, w=3:
|
||||
lowpt1[3]=3, lowpt2[3]=INF, dfs_num[2]=2.
|
||||
lowpt2[3] >= dfs_num[2] -> case 3 -> phi = 3*lowpt1[3]+2 = 11.
|
||||
"""
|
||||
e1: int = self.eids[1]
|
||||
key: int = phi_key(
|
||||
v=2, eid=e1, pt=self.pt, graph=self.g,
|
||||
)
|
||||
# lowpt1[3]=3, case 3: 3*3+2 = 11
|
||||
self.assertEqual(key, 3 * 3 + 2)
|
||||
|
||||
def test_tree_edge_case3_greater_than_case1(self) -> None:
|
||||
"""Test that case-3 phi > case-1 phi for ordering.
|
||||
|
||||
Case 1: phi = 3*lowpt1[w]
|
||||
Case 3: phi = 3*lowpt1[w]+2
|
||||
Case 3 must be strictly greater than case 1 for same lowpt1.
|
||||
"""
|
||||
e0: int = self.eids[0]
|
||||
key_v1: int = phi_key(
|
||||
v=1, eid=e0, pt=self.pt, graph=self.g,
|
||||
)
|
||||
# Case 3 value (8) > case 1 value (6) for lowpt1[2]=2
|
||||
self.assertGreater(key_v1, 3 * 2)
|
||||
|
||||
|
||||
class TestPhiKeyK3Frond(unittest.TestCase):
|
||||
"""Tests for phi_key correctness on the triangle K3.
|
||||
|
||||
K3: vertices 1,2,3; edges 1-2 (tree), 2-3 (tree), 1-3 (frond).
|
||||
DFS from 1: dfs_num = {1:1, 2:2, 3:3}.
|
||||
Frond: 1-3 (from vertex 3 to ancestor 1).
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Build palm tree for K3 and gather data for phi_key tests."""
|
||||
g: MultiGraph
|
||||
g, eids = _make_k3()
|
||||
self.g: MultiGraph = g
|
||||
"""The K3 graph under test."""
|
||||
self.eids: list[int] = eids
|
||||
"""The edge IDs of the K3 graph."""
|
||||
self.pt: PalmTree = build_palm_tree(g, 1)
|
||||
"""The palm tree for the graph."""
|
||||
|
||||
def test_frond_phi_uses_w_dfs_num(self) -> None:
|
||||
"""Test phi_key for frond e2 (1-3) from v=3 uses dfs_num[w=1].
|
||||
|
||||
Frond from v=3 to ancestor w=1: dfs_num[w]=1.
|
||||
phi = 3 * dfs_num[w] + 1 = 3 * 1 + 1 = 4.
|
||||
"""
|
||||
e2: int = self.eids[2] # edge 1-3 (frond)
|
||||
# The frond is traversed from vertex 3 toward ancestor 1.
|
||||
# We need to identify which end is the frond source.
|
||||
# In the palm tree, frond is from v=3 (dfs_num=3) to w=1
|
||||
# (dfs_num=1).
|
||||
# phi_key called with v=3, w=1 (ancestor), eid=e2.
|
||||
key: int = phi_key(
|
||||
v=3, eid=e2, pt=self.pt, graph=self.g,
|
||||
)
|
||||
# Correct formula: 3 * dfs_num[w=1] + 1 = 3*1+1 = 4
|
||||
self.assertEqual(key, 3 * 1 + 1)
|
||||
|
||||
def test_frond_phi_different_from_v_formula(self) -> None:
|
||||
"""Test that frond phi uses w (not v) DFS number.
|
||||
|
||||
The buggy formula uses dfs_num[v] (= 3) giving 3*3+2 = 11.
|
||||
The correct formula uses dfs_num[w] (= 1) giving 3*1+1 = 4.
|
||||
These must differ.
|
||||
"""
|
||||
e2: int = self.eids[2]
|
||||
key: int = phi_key(
|
||||
v=3, eid=e2, pt=self.pt, graph=self.g,
|
||||
)
|
||||
# The buggy value would be 3*dfs_num[v=3]+2 = 3*3+2 = 11.
|
||||
# The correct value is 3*dfs_num[w=1]+1 = 4.
|
||||
self.assertNotEqual(key, 11)
|
||||
self.assertEqual(key, 4)
|
||||
|
||||
def test_frond_phi_less_than_tree_edge_case3(self) -> None:
|
||||
"""Test ordering: frond phi < tree-edge case-3 phi.
|
||||
|
||||
The frond phi (4) should be less than a case-3 tree-edge phi
|
||||
with the same lowpt1 (3*1+2=5), ensuring correct DFS order.
|
||||
"""
|
||||
e2: int = self.eids[2]
|
||||
frond_key: int = phi_key(
|
||||
v=3, eid=e2, pt=self.pt, graph=self.g,
|
||||
)
|
||||
# Frond phi = 3*1+1=4; case-3 tree-edge phi at lowpt1=1 = 3*1+2=5
|
||||
self.assertLess(frond_key, 3 * 1 + 2)
|
||||
|
||||
def test_tree_edge_case1_condition(self) -> None:
|
||||
"""Test phi_key for tree edge where lowpt2[w] < dfs_num[v].
|
||||
|
||||
For K3, after sorting: tree edge v=1, w=2:
|
||||
lowpt1[2]=1, lowpt2[2]=INF, dfs_num[1]=1.
|
||||
lowpt2[2]=INF >= dfs_num[1]=1 -> case 3 -> phi = 3*1+2 = 5.
|
||||
"""
|
||||
e0: int = self.eids[0] # edge 1-2 (tree edge)
|
||||
key: int = phi_key(
|
||||
v=1, eid=e0, pt=self.pt, graph=self.g,
|
||||
)
|
||||
# lowpt1[2]=1, lowpt2[2]=INF >= dfs_num[1]=1 -> case 3:
|
||||
# phi = 3*1+2 = 5
|
||||
self.assertEqual(key, 3 * 1 + 2)
|
||||
2853
tests/test_spqrtree.py
Normal file
2853
tests/test_spqrtree.py
Normal file
File diff suppressed because it is too large
Load Diff
2787
tests/test_triconnected.py
Normal file
2787
tests/test_triconnected.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user