1
0
Fork 0

Compare commits

...

10 Commits

17 changed files with 432 additions and 295 deletions

View File

@ -1,39 +1,73 @@
# Maximum Weighted Matching
This repository contains a Python 3 implementation of maximum weighted matching in general graphs.
This repository contains implementations of maximum weighted matching for general graphs.
In graph theory, a _matching_ is a subset of edges that does not use any vertex more than once.
For an edge-weighted graph, a _maximum weight matching_ is a matching that achieves
the largest possible sum of weights of matched edges.
The code in this repository is based on a variant of the blossom algorithm that runs in
_O(n<sup>3</sup>)_ steps.
_O(n*m*log(n))_ steps.
See the file [Algorithm.md](doc/Algorithm.md) for a detailed description.
You may find this repository useful if ...
- you want a stand-alone, pure Python module that calculates maximum weight matchings
with reasonable efficiency;
- or you want to play around with a maximum weight matching algorithm to learn how it works.
## Python
This repository is probably not the best place if ...
The folder [python/](python/) contains a Python 3 package `mwmatching` that implements
maximum weighted matching.
The Python code is self-contained -- it has no dependencies outside the standard library.
- you need a very fast routine that quickly matches large graphs (more than ~ 1000 vertices).
In that case I recommend [LEMON](http://lemon.cs.elte.hu/trac/lemon),
a C++ library that provides a blazingly fast implementation.
- or you want a high quality Python package that provides abstract graph data structures
and graph algorithms.
In that case I recommend [NetworkX](https://networkx.org/).
- or you are only interested in bipartite graphs.
There are simpler and faster algorithms for matching bipartite graphs.
To use the package, set your `PYTHONPATH` to the location of the package `mwmatching`.
Alternatively, you can install the package into your Python environment by running
```
cd python
pip install .
```
Using the algorithm is easy.
You describe the input graph by listing its edges.
Each edge is represented as a pair of vertex indices and the weight of the edge.
The example below finds a matching in a graph with 5 vertices and 5 edges.
The maximum weight matching contains two edges and has total weight 11.
```
from mwmatching import maximum_weight_matching
edges = [(0, 1, 3), (1, 2, 8), (1, 4, 6), (2, 3, 5), (2, 4, 7)]
matching = maximum_weight_matching(edges)
print(matching) # prints [(2, 5), (3, 4)]
```
## C++
The folder [cpp/](cpp/) contains a header-only C++ implementation of maximum weighted matching.
**NOTE:**
The C++ code currently implements a slower algorithm that runs in _O(n<sup>3</sup>)_ steps.
I plan to eventually update the C++ code to implement the faster _O(n*m*log(n))_ algorithm.
The C++ code is self-contained and can easily be linked into an application.
It is also reasonably efficient.
For serious use cases, [LEMON](http://lemon.cs.elte.hu/trac/lemon) may be a better choice.
LEMON is a C++ library that provides a very fast and robust implementation of
maximum weighted matching and many other graph algorithms.
To my knowledge, it is the only free software library that provides a high-quality
matching algorithm.
## Repository structure
```
python/
mwmatching.py : Python implementation of maximum weight matching
test_mwmatching.py : Unit tests
mwmatching/ : Python package for maximum weight matching
__init__.py
algorithm.py : Algorithm implementation
datastruct.py : Internal data structures
tests/
test_algorithm : Unit tests for the algorithm
test_datastruct.py : Unit tests for data structures
run_matching.py : Command-line program to run the matching algorithm
cpp/
@ -76,6 +110,11 @@ My implementation follows the description of the algorithm as it appears in the
However, earlier versions of the algorithm were invented and improved by several other scientists.
See the file [Algorithm.md](doc/Algorithm.md) for links to the most important papers.
I used some ideas from the source code of the `MaxWeightedMatching` class in
[LEMON](http://lemon.cs.elto.hu/trac/lemon):
the technique to implement lazy updates of vertex dual variables,
and the approach to re-use alternating trees after augmenting the matching.
I used Fortran programs `hardcard.f`, `t.f` and `tt.f` by R. B. Mattingly and N. Ritchey
to generate test graphs.
These programs are part of the DIMACS Network Flows and Matching implementation challenge.
@ -83,7 +122,7 @@ They can be found in the
[DIMACS Netflow archive](http://archive.dimacs.rutgers.edu/pub/netflow/).
To check the correctness of my results, I used other maximum weight matching solvers:
the `MaxWeightedMatching` module in [LEMON](http://lemon.cs.elte.hu/trac/lemon),
the `MaxWeightedMatching` module in LEMON,
and the program
[`wmatch`](http://archive.dimacs.rutgers.edu/pub/netflow/matching/weighted/solver-1/)
by Edward Rothberg.
@ -94,7 +133,7 @@ by Edward Rothberg.
The following license applies to the software in this repository, excluding the folder `doc`.
This license is sometimes called the MIT License or the Expat License:
> Copyright (c) 2023 Joris van Rantwijk
> Copyright (c) 2023-2024 Joris van Rantwijk
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
>

View File

@ -0,0 +1,11 @@
"""
Algorithm for finding a maximum weight matching in general graphs.
"""
__all__ = ["maximum_weight_matching",
"adjust_weights_for_maximum_cardinality_matching",
"MatchingError"]
from .algorithm import (maximum_weight_matching,
adjust_weights_for_maximum_cardinality_matching,
MatchingError)

View File

@ -10,7 +10,7 @@ import math
from collections.abc import Sequence
from typing import NamedTuple, Optional
from datastruct import UnionFindQueue, PriorityQueue
from .datastruct import UnionFindQueue, PriorityQueue
def maximum_weight_matching(
@ -66,10 +66,10 @@ def maximum_weight_matching(
return []
# Initialize graph representation.
graph = _GraphInfo(edges)
graph = GraphInfo(edges)
# Initialize the matching algorithm.
ctx = _MatchingContext(graph)
ctx = MatchingContext(graph)
ctx.start()
# Improve the solution until no further improvement is possible.
@ -92,7 +92,7 @@ def maximum_weight_matching(
# there is a bug in the matching algorithm.
# Verification only works reliably for integer weights.
if graph.integer_weights:
_verify_optimum(ctx)
verify_optimum(ctx)
return pairs
@ -277,7 +277,7 @@ def _remove_negative_weight_edges(
return edges
class _GraphInfo:
class GraphInfo:
"""Representation of the input graph.
These data remain unchanged while the algorithm runs.
@ -328,12 +328,12 @@ class _GraphInfo:
# Each vertex may be labeled "S" (outer) or "T" (inner) or be unlabeled.
_LABEL_NONE = 0
_LABEL_S = 1
_LABEL_T = 2
LABEL_NONE = 0
LABEL_S = 1
LABEL_T = 2
class _Blossom:
class Blossom:
"""Represents a blossom in a partially matched graph.
A blossom is an odd-length alternating cycle over sub-blossoms.
@ -361,7 +361,7 @@ class _Blossom:
#
# If this is a top-level blossom,
# "parent = None".
self.parent: Optional[_NonTrivialBlossom] = None
self.parent: Optional[NonTrivialBlossom] = None
# "base_vertex" is the vertex index of the base of the blossom.
# This is the unique vertex which is contained in the blossom
@ -374,7 +374,7 @@ class _Blossom:
# A top-level blossom that is part of an alternating tree,
# has label S or T. An unlabeled top-level blossom is not part
# of any alternating tree.
self.label: int = _LABEL_NONE
self.label: int = LABEL_NONE
# A labeled top-level blossoms keeps track of the edge through which
# it is attached to the alternating tree.
@ -389,12 +389,11 @@ class _Blossom:
# "tree_blossoms" is the set of all top-level blossoms that belong
# to the same alternating tree. The same set instance is shared by
# all top-level blossoms in the tree.
self.tree_blossoms: "Optional[set[_Blossom]]" = None
self.tree_blossoms: Optional[set[Blossom]] = None
# Each top-level blossom maintains a union-find datastructure
# containing all vertices in the blossom.
self.vertex_set: "UnionFindQueue[_Blossom, int]"
self.vertex_set = UnionFindQueue(self)
self.vertex_set: UnionFindQueue[Blossom, int] = UnionFindQueue(self)
# If this is a top-level unlabeled blossom with an edge to an
# S-blossom, "delta2_node" is the corresponding node in the delta2
@ -415,7 +414,7 @@ class _Blossom:
return [self.base_vertex]
class _NonTrivialBlossom(_Blossom):
class NonTrivialBlossom(Blossom):
"""Represents a non-trivial blossom in a partially matched graph.
A non-trivial blossom is a blossom that contains multiple sub-blossoms
@ -436,7 +435,7 @@ class _NonTrivialBlossom(_Blossom):
def __init__(
self,
subblossoms: list[_Blossom],
subblossoms: list[Blossom],
edges: list[tuple[int, int]]
) -> None:
"""Initialize a new blossom."""
@ -454,7 +453,7 @@ class _NonTrivialBlossom(_Blossom):
#
# "subblossoms[0]" is the start and end of the alternating cycle.
# "subblossoms[0]" contains the base vertex of the blossom.
self.subblossoms: list[_Blossom] = subblossoms
self.subblossoms: list[Blossom] = subblossoms
# "edges" is a list of edges linking the sub-blossoms.
# Each edge is represented as an ordered pair "(x, y)" where "x"
@ -491,13 +490,13 @@ class _NonTrivialBlossom(_Blossom):
"""Return a list of vertex indices contained in the blossom."""
# Use an explicit stack to avoid deep recursion.
stack: list[_NonTrivialBlossom] = [self]
stack: list[NonTrivialBlossom] = [self]
nodes: list[int] = []
while stack:
b = stack.pop()
for sub in b.subblossoms:
if isinstance(sub, _NonTrivialBlossom):
if isinstance(sub, NonTrivialBlossom):
stack.append(sub)
else:
nodes.append(sub.base_vertex)
@ -505,21 +504,21 @@ class _NonTrivialBlossom(_Blossom):
return nodes
class _AlternatingPath(NamedTuple):
class AlternatingPath(NamedTuple):
"""Represents a list of edges forming an alternating path or an
alternating cycle."""
edges: list[tuple[int, int]]
is_cycle: bool
class _MatchingContext:
class MatchingContext:
"""Holds all data used by the matching algorithm.
It contains a partial solution of the matching problem and several
auxiliary data structures.
"""
def __init__(self, graph: _GraphInfo) -> None:
def __init__(self, graph: GraphInfo) -> None:
"""Set up the initial state of the matching algorithm."""
num_vertex = graph.num_vertex
@ -545,14 +544,14 @@ class _MatchingContext:
#
# "trivial_blossom[x]" is the trivial blossom that contains only
# vertex "x".
self.trivial_blossom: list[_Blossom] = [_Blossom(x)
self.trivial_blossom: list[Blossom] = [Blossom(x)
for x in range(num_vertex)]
# Non-trivial blossoms may be created and destroyed during
# the course of the algorithm.
#
# Initially there are no non-trivial blossoms.
self.nontrivial_blossom: set[_NonTrivialBlossom] = set()
self.nontrivial_blossom: set[NonTrivialBlossom] = set()
# "vertex_set_node[x]" represents the vertex "x" inside the
# union-find datastructure of its top-level blossom.
@ -593,7 +592,7 @@ class _MatchingContext:
# Queue containing unlabeled top-level blossoms that have an edge to
# an S-blossom. The priority of a blossom is 2 times its least slack
# to an S blossom, plus 2 times the running sum of delta steps.
self.delta2_queue: PriorityQueue[_Blossom] = PriorityQueue()
self.delta2_queue: PriorityQueue[Blossom] = PriorityQueue()
# Queue containing edges between S-vertices in different top-level
# blossoms. The priority of an edge is its slack plus 2 times the
@ -605,7 +604,7 @@ class _MatchingContext:
# Queue containing top-level non-trivial T-blossoms.
# The priority of a blossom is its dual plus 2 times the running
# sum of delta steps.
self.delta4_queue: PriorityQueue[_NonTrivialBlossom] = PriorityQueue()
self.delta4_queue: PriorityQueue[NonTrivialBlossom] = PriorityQueue()
# For each T-vertex or unlabeled vertex "x",
# "vertex_sedge_queue[x]" is a queue of edges between "x" and any
@ -648,7 +647,7 @@ class _MatchingContext:
(x, y, w) = self.graph.edges[e]
return self.vertex_dual_2x[x] + self.vertex_dual_2x[y] - 2 * w
def delta2_add_edge(self, e: int, y: int, by: _Blossom) -> None:
def delta2_add_edge(self, e: int, y: int, by: Blossom) -> None:
"""Add edge "e" for delta2 tracking.
Edge "e" connects an S-vertex to a T-vertex or unlabeled vertex "y".
@ -674,14 +673,14 @@ class _MatchingContext:
# If the blossom is unlabeled and the new edge becomes its least-slack
# S-edge, insert or update the blossom in the global delta2 queue.
if by.label == _LABEL_NONE:
if by.label == LABEL_NONE:
prio += by.vertex_dual_offset
if by.delta2_node is None:
by.delta2_node = self.delta2_queue.insert(prio, by)
elif prio < by.delta2_node.prio:
self.delta2_queue.decrease_prio(by.delta2_node, prio)
def delta2_remove_edge(self, e: int, y: int, by: _Blossom) -> None:
def delta2_remove_edge(self, e: int, y: int, by: Blossom) -> None:
"""Remove edge "e" from delta2 tracking.
This function is called if an S-vertex becomes unlabeled,
@ -705,7 +704,7 @@ class _MatchingContext:
# If necessary, update the priority of "y" in its UnionFindQueue.
if prio > self.vertex_set_node[y].prio:
self.vertex_set_node[y].set_prio(prio)
if by.label == _LABEL_NONE:
if by.label == LABEL_NONE:
# Update or delete the blossom in the global delta2 queue.
assert by.delta2_node is not None
prio = by.vertex_set.min_prio()
@ -718,7 +717,7 @@ class _MatchingContext:
self.delta2_queue.delete(by.delta2_node)
by.delta2_node = None
def delta2_enable_blossom(self, blossom: _Blossom) -> None:
def delta2_enable_blossom(self, blossom: Blossom) -> None:
"""Enable delta2 tracking for "blossom".
This function is called when a blossom becomes an unlabeled top-level
@ -733,7 +732,7 @@ class _MatchingContext:
prio += blossom.vertex_dual_offset
blossom.delta2_node = self.delta2_queue.insert(prio, blossom)
def delta2_disable_blossom(self, blossom: _Blossom) -> None:
def delta2_disable_blossom(self, blossom: Blossom) -> None:
"""Disable delta2 tracking for "blossom".
The blossom will be removed from the global delta2 queue.
@ -780,7 +779,7 @@ class _MatchingContext:
prio = delta2_node.prio
slack_2x = prio - self.delta_sum_2x
assert blossom.parent is None
assert blossom.label == _LABEL_NONE
assert blossom.label == LABEL_NONE
x = blossom.vertex_set.min_elem()
e = self.vertex_sedge_queue[x].find_min().data
@ -840,7 +839,7 @@ class _MatchingContext:
(x, y, _w) = self.graph.edges[e]
bx = self.vertex_set_node[x].find()
by = self.vertex_set_node[y].find()
assert (bx.label == _LABEL_S) and (by.label == _LABEL_S)
assert (bx.label == LABEL_S) and (by.label == LABEL_S)
if bx is not by:
slack = delta3_node.prio - self.delta_sum_2x
return (e, slack)
@ -859,7 +858,7 @@ class _MatchingContext:
# Managing blossom labels:
#
def assign_blossom_label_s(self, blossom: _Blossom) -> None:
def assign_blossom_label_s(self, blossom: Blossom) -> None:
"""Change an unlabeled top-level blossom into an S-blossom.
For a blossom with "j" vertices and "k" incident edges,
@ -870,8 +869,8 @@ class _MatchingContext:
"""
assert blossom.parent is None
assert blossom.label == _LABEL_NONE
blossom.label = _LABEL_S
assert blossom.label == LABEL_NONE
blossom.label = LABEL_S
# Labeled blossoms must not be in the delta2 queue.
self.delta2_disable_blossom(blossom)
@ -887,7 +886,7 @@ class _MatchingContext:
# The value of blossom.dual_var must be adjusted accordingly
# when the blossom changes from unlabeled to S-blossom.
#
if isinstance(blossom, _NonTrivialBlossom):
if isinstance(blossom, NonTrivialBlossom):
blossom.dual_var -= self.delta_sum_2x
# Apply pending updates to vertex dual variables and prepare
@ -916,20 +915,20 @@ class _MatchingContext:
# Add the new S-vertices to the scan queue.
self.scan_queue.extend(vertices)
def assign_blossom_label_t(self, blossom: _Blossom) -> None:
def assign_blossom_label_t(self, blossom: Blossom) -> None:
"""Change an unlabeled top-level blossom into a T-blossom.
This function takes time O(log(n)).
"""
assert blossom.parent is None
assert blossom.label == _LABEL_NONE
blossom.label = _LABEL_T
assert blossom.label == LABEL_NONE
blossom.label = LABEL_T
# Labeled blossoms must not be in the delta2 queue.
self.delta2_disable_blossom(blossom)
if isinstance(blossom, _NonTrivialBlossom):
if isinstance(blossom, NonTrivialBlossom):
# Adjust for lazy updating of T-blossom dual variables.
#
@ -962,7 +961,7 @@ class _MatchingContext:
#
blossom.vertex_dual_offset -= self.delta_sum_2x
def remove_blossom_label_s(self, blossom: _Blossom) -> None:
def remove_blossom_label_s(self, blossom: Blossom) -> None:
"""Change a top-level S-blossom into an unlabeled blossom.
For a blossom with "j" vertices and "k" incident edges,
@ -973,11 +972,11 @@ class _MatchingContext:
"""
assert blossom.parent is None
assert blossom.label == _LABEL_S
blossom.label = _LABEL_NONE
assert blossom.label == LABEL_S
blossom.label = LABEL_NONE
# Unwind lazy delta updates to the S-blossom dual variable.
if isinstance(blossom, _NonTrivialBlossom):
if isinstance(blossom, NonTrivialBlossom):
blossom.dual_var += self.delta_sum_2x
assert blossom.vertex_dual_offset == 0
@ -1002,7 +1001,7 @@ class _MatchingContext:
self.delta3_remove_edge(e)
by = self.vertex_set_node[y].find()
if by.label == _LABEL_S:
if by.label == LABEL_S:
# Edge "e" connects unlabeled vertex "x" to S-vertex "y".
# It must be tracked for delta2 via vertex "x".
self.delta2_add_edge(e, x, blossom)
@ -1013,17 +1012,17 @@ class _MatchingContext:
# removed now.
self.delta2_remove_edge(e, y, by)
def remove_blossom_label_t(self, blossom: _Blossom) -> None:
def remove_blossom_label_t(self, blossom: Blossom) -> None:
"""Change a top-level T-blossom into an unlabeled blossom.
This function takes time O(log(n)).
"""
assert blossom.parent is None
assert blossom.label == _LABEL_T
blossom.label = _LABEL_NONE
assert blossom.label == LABEL_T
blossom.label = LABEL_NONE
if isinstance(blossom, _NonTrivialBlossom):
if isinstance(blossom, NonTrivialBlossom):
# Unlabeled blossoms are not tracked in the delta4 queue.
assert blossom.delta4_node is not None
@ -1039,52 +1038,36 @@ class _MatchingContext:
# Enable unlabeled top-level blossom for delta2 tracking.
self.delta2_enable_blossom(blossom)
def change_s_blossom_to_subblossom(self, blossom: _Blossom) -> None:
def change_s_blossom_to_subblossom(self, blossom: Blossom) -> None:
"""Change a top-level S-blossom into an S-subblossom.
This function takes time O(1).
"""
assert blossom.parent is None
assert blossom.label == _LABEL_S
blossom.label = _LABEL_NONE
assert blossom.label == LABEL_S
blossom.label = LABEL_NONE
# Unwind lazy delta updates to the S-blossom dual variable.
if isinstance(blossom, _NonTrivialBlossom):
if isinstance(blossom, NonTrivialBlossom):
blossom.dual_var += self.delta_sum_2x
#
# General support routines:
#
def reset_blossom_label(self, blossom: _Blossom) -> None:
def reset_blossom_label(self, blossom: Blossom) -> None:
"""Remove blossom label."""
assert blossom.parent is None
assert blossom.label != _LABEL_NONE
assert blossom.label != LABEL_NONE
if blossom.label == _LABEL_S:
if blossom.label == LABEL_S:
self.remove_blossom_label_s(blossom)
else:
self.remove_blossom_label_t(blossom)
def _check_alternating_tree_consistency(self) -> None:
"""TODO -- remove this function, only for debugging"""
for blossom in itertools.chain(self.trivial_blossom,
self.nontrivial_blossom):
if (blossom.parent is None) and (blossom.label != _LABEL_NONE):
assert blossom.tree_blossoms is not None
assert blossom in blossom.tree_blossoms
if blossom.tree_edge is not None:
bx = self.vertex_set_node[blossom.tree_edge[0]].find()
by = self.vertex_set_node[blossom.tree_edge[1]].find()
assert bx.tree_blossoms is blossom.tree_blossoms
assert by.tree_blossoms is blossom.tree_blossoms
else:
assert blossom.tree_edge is None
assert blossom.tree_blossoms is None
def remove_alternating_tree(self, tree_blossoms: set[_Blossom]) -> None:
def remove_alternating_tree(self, tree_blossoms: set[Blossom]) -> None:
"""Reset the alternating tree consisting of the specified blossoms.
Marks the blossoms as unlabeled.
@ -1093,13 +1076,13 @@ class _MatchingContext:
This function takes time O((n + m) * log(n)).
"""
for blossom in tree_blossoms:
assert blossom.label != _LABEL_NONE
assert blossom.label != LABEL_NONE
assert blossom.tree_blossoms is tree_blossoms
self.reset_blossom_label(blossom)
blossom.tree_edge = None
blossom.tree_blossoms = None
def trace_alternating_paths(self, x: int, y: int) -> _AlternatingPath:
def trace_alternating_paths(self, x: int, y: int) -> AlternatingPath:
"""Trace back through the alternating trees from vertices "x" and "y".
If both vertices are part of the same alternating tree, this function
@ -1119,7 +1102,7 @@ class _MatchingContext:
blossoms.
"""
marked_blossoms: list[_Blossom] = []
marked_blossoms: list[Blossom] = []
# "xedges" is a list of edges used while tracing from "x".
# "yedges" is a list of edges used while tracing from "y".
@ -1129,7 +1112,7 @@ class _MatchingContext:
# "first_common" is the first common ancestor of "x" and "y"
# in the alternating tree, or None if there is no common ancestor.
first_common: Optional[_Blossom] = None
first_common: Optional[Blossom] = None
# Alternate between tracing the path from "x" and the path from "y".
# This ensures that the search time is bounded by the size of the
@ -1179,14 +1162,14 @@ class _MatchingContext:
# Any S-to-S alternating path must have odd length.
assert len(path_edges) % 2 == 1
return _AlternatingPath(edges=path_edges,
return AlternatingPath(edges=path_edges,
is_cycle=(first_common is not None))
#
# Merge and expand blossoms:
#
def make_blossom(self, path: _AlternatingPath) -> None:
def make_blossom(self, path: AlternatingPath) -> None:
"""Create a new blossom from an alternating cycle.
Assign label S to the new blossom.
@ -1214,21 +1197,21 @@ class _MatchingContext:
assert subblossoms[1:] == subblossoms_next[:-1]
# Blossom must start and end with an S-sub-blossom.
assert subblossoms[0].label == _LABEL_S
assert subblossoms[0].label == LABEL_S
# Remove blossom labels.
# Mark vertices inside former T-blossoms as S-vertices.
for sub in subblossoms:
if sub.label == _LABEL_T:
if sub.label == LABEL_T:
self.remove_blossom_label_t(sub)
self.assign_blossom_label_s(sub)
self.change_s_blossom_to_subblossom(sub)
# Create the new blossom object.
blossom = _NonTrivialBlossom(subblossoms, path.edges)
blossom = NonTrivialBlossom(subblossoms, path.edges)
# Assign label S to the new blossom.
blossom.label = _LABEL_S
blossom.label = LABEL_S
# Prepare for lazy updating of S-blossom dual variable.
blossom.dual_var = -self.delta_sum_2x
@ -1257,9 +1240,9 @@ class _MatchingContext:
@staticmethod
def find_path_through_blossom(
blossom: _NonTrivialBlossom,
sub: _Blossom
) -> tuple[list[_Blossom], list[tuple[int, int]]]:
blossom: NonTrivialBlossom,
sub: Blossom
) -> tuple[list[Blossom], list[tuple[int, int]]]:
"""Construct a path with an even number of edges through the
specified blossom, from sub-blossom "sub" to the base of "blossom".
@ -1285,14 +1268,14 @@ class _MatchingContext:
return (nodes, edges)
def expand_unlabeled_blossom(self, blossom: _NonTrivialBlossom) -> None:
def expand_unlabeled_blossom(self, blossom: NonTrivialBlossom) -> None:
"""Expand the specified unlabeled blossom.
This function takes total time O(n * log(n)) per stage.
"""
assert blossom.parent is None
assert blossom.label == _LABEL_NONE
assert blossom.label == LABEL_NONE
# Remove blossom from the delta2 queue.
self.delta2_disable_blossom(blossom)
@ -1306,7 +1289,7 @@ class _MatchingContext:
# Convert sub-blossoms into top-level blossoms.
for sub in blossom.subblossoms:
assert sub.label == _LABEL_NONE
assert sub.label == LABEL_NONE
sub.parent = None
assert sub.vertex_dual_offset == 0
@ -1320,14 +1303,14 @@ class _MatchingContext:
# Delete the expanded blossom.
self.nontrivial_blossom.remove(blossom)
def expand_t_blossom(self, blossom: _NonTrivialBlossom) -> None:
def expand_t_blossom(self, blossom: NonTrivialBlossom) -> None:
"""Expand the specified T-blossom.
This function takes total time O(n * log(n) + m) per stage.
"""
assert blossom.parent is None
assert blossom.label == _LABEL_T
assert blossom.label == LABEL_T
assert blossom.delta2_node is None
# Remove blossom from its alternating tree.
@ -1391,9 +1374,9 @@ class _MatchingContext:
def augment_blossom_rec(
self,
blossom: _NonTrivialBlossom,
sub: _Blossom,
stack: list[tuple[_NonTrivialBlossom, _Blossom]]
blossom: NonTrivialBlossom,
sub: Blossom,
stack: list[tuple[NonTrivialBlossom, Blossom]]
) -> None:
"""Augment along an alternating path through the specified blossom,
from sub-blossom "sub" to the base vertex of the blossom.
@ -1430,11 +1413,11 @@ class _MatchingContext:
# Augment through the subblossoms touching the edge (x, y).
# Nothing needs to be done for trivial subblossoms.
bx = path_nodes[p+1]
if isinstance(bx, _NonTrivialBlossom):
if isinstance(bx, NonTrivialBlossom):
stack.append((bx, self.trivial_blossom[x]))
by = path_nodes[p+2]
if isinstance(by, _NonTrivialBlossom):
if isinstance(by, NonTrivialBlossom):
stack.append((by, self.trivial_blossom[y]))
# Rotate the subblossom list so the new base ends up in position 0.
@ -1450,8 +1433,8 @@ class _MatchingContext:
def augment_blossom(
self,
blossom: _NonTrivialBlossom,
sub: _Blossom
blossom: NonTrivialBlossom,
sub: Blossom
) -> None:
"""Augment along an alternating path through the specified blossom,
from sub-blossom "sub" to the base vertex of the blossom.
@ -1486,7 +1469,7 @@ class _MatchingContext:
# Augment "blossom" from "sub" to the base vertex.
self.augment_blossom_rec(blossom, sub, stack)
def augment_matching(self, path: _AlternatingPath) -> None:
def augment_matching(self, path: AlternatingPath) -> None:
"""Augment the matching through the specified augmenting path.
This function takes time O(n).
@ -1515,11 +1498,11 @@ class _MatchingContext:
# Augment the non-trivial blossoms on either side of this edge.
# No action is necessary for trivial blossoms.
bx = self.vertex_set_node[x].find()
if isinstance(bx, _NonTrivialBlossom):
if isinstance(bx, NonTrivialBlossom):
self.augment_blossom(bx, self.trivial_blossom[x])
by = self.vertex_set_node[y].find()
if isinstance(by, _NonTrivialBlossom):
if isinstance(by, NonTrivialBlossom):
self.augment_blossom(by, self.trivial_blossom[y])
# Pull the edge into the matching.
@ -1551,7 +1534,7 @@ class _MatchingContext:
assert y != -1
by = self.vertex_set_node[y].find()
assert by.label == _LABEL_T
assert by.label == LABEL_T
assert by.tree_blossoms is not None
# Attach the blossom that contains "x" to the alternating tree.
@ -1575,10 +1558,10 @@ class _MatchingContext:
bx = self.vertex_set_node[x].find()
by = self.vertex_set_node[y].find()
assert bx.label == _LABEL_S
assert bx.label == LABEL_S
# Expand zero-dual blossoms before assigning label T.
while isinstance(by, _NonTrivialBlossom) and (by.dual_var == 0):
while isinstance(by, NonTrivialBlossom) and (by.dual_var == 0):
self.expand_unlabeled_blossom(by)
by = self.vertex_set_node[y].find()
@ -1617,8 +1600,8 @@ class _MatchingContext:
bx = self.vertex_set_node[x].find()
by = self.vertex_set_node[y].find()
assert bx.label == _LABEL_S
assert by.label == _LABEL_S
assert bx.label == LABEL_S
assert by.label == LABEL_S
assert bx is not by
# Trace back through the alternating trees from "x" and "y".
@ -1680,7 +1663,7 @@ class _MatchingContext:
# Double-check that "x" is an S-vertex.
bx = self.vertex_set_node[x].find()
assert bx.label == _LABEL_S
assert bx.label == LABEL_S
# Scan the edges that are incident on "x".
# This loop runs through O(m) iterations per stage.
@ -1703,7 +1686,7 @@ class _MatchingContext:
if bx is by:
continue
if by.label == _LABEL_S:
if by.label == LABEL_S:
self.delta3_add_edge(e)
else:
self.delta2_add_edge(e, y, by)
@ -1716,7 +1699,7 @@ class _MatchingContext:
def calc_dual_delta_step(
self
) -> tuple[int, float, int, Optional[_NonTrivialBlossom]]:
) -> tuple[int, float, int, Optional[NonTrivialBlossom]]:
"""Calculate a delta step in the dual LPP problem.
This function returns the minimum of the 4 types of delta values,
@ -1740,7 +1723,7 @@ class _MatchingContext:
Tuple (delta_type, delta_2x, delta_edge, delta_blossom).
"""
delta_edge = -1
delta_blossom: Optional[_NonTrivialBlossom] = None
delta_blossom: Optional[NonTrivialBlossom] = None
# Compute delta1: minimum dual variable of any S-vertex.
# All unmatched vertices have the same dual value, and this is
@ -1770,7 +1753,7 @@ class _MatchingContext:
# This takes time O(log(n)).
if not self.delta4_queue.empty():
blossom = self.delta4_queue.find_min().data
assert blossom.label == _LABEL_T
assert blossom.label == LABEL_T
assert blossom.parent is None
blossom_dual = blossom.dual_var - self.delta_sum_2x
if blossom_dual <= delta_2x:
@ -1828,8 +1811,6 @@ class _MatchingContext:
# This loop runs through at most O(n) iterations per stage.
while True:
# self._check_alternating_tree_consistency() # TODO -- remove this
# Consider the incident edges of newly labeled S-vertices.
self.scan_new_s_vertices()
@ -1847,7 +1828,7 @@ class _MatchingContext:
# Use the edge from S-vertex to unlabeled vertex that got
# unlocked through the delta update.
(x, y, _w) = self.graph.edges[delta_edge]
if self.vertex_set_node[x].find().label != _LABEL_S:
if self.vertex_set_node[x].find().label != LABEL_S:
(x, y) = (y, x)
self.extend_tree_s_to_t(x, y)
@ -1886,9 +1867,9 @@ class _MatchingContext:
self.nontrivial_blossom):
# Remove blossom label.
if (blossom.parent is None) and (blossom.label != _LABEL_NONE):
if (blossom.parent is None) and (blossom.label != LABEL_NONE):
self.reset_blossom_label(blossom)
assert blossom.label == _LABEL_NONE
assert blossom.label == LABEL_NONE
# Remove blossom from alternating tree.
blossom.tree_edge = None
@ -1906,8 +1887,8 @@ class _MatchingContext:
def _verify_blossom_edges(
ctx: _MatchingContext,
blossom: _NonTrivialBlossom,
ctx: MatchingContext,
blossom: NonTrivialBlossom,
edge_slack_2x: list[float]
) -> None:
"""Descend down the blossom tree to find edges that are contained
@ -1943,7 +1924,7 @@ def _verify_blossom_edges(
path_num_matched: list[int] = [0]
# Use an explicit stack to avoid deep recursion.
stack: list[tuple[_NonTrivialBlossom, int]] = [(blossom, -1)]
stack: list[tuple[NonTrivialBlossom, int]] = [(blossom, -1)]
while stack:
(blossom, p) = stack[-1]
@ -1969,7 +1950,7 @@ def _verify_blossom_edges(
# Examine the next sub-blossom at the current level.
sub = blossom.subblossoms[p]
if isinstance(sub, _NonTrivialBlossom):
if isinstance(sub, NonTrivialBlossom):
# Prepare to descent into the selected sub-blossom and
# scan it recursively.
stack.append((sub, -1))
@ -2031,7 +2012,7 @@ def _verify_blossom_edges(
stack.pop()
def _verify_optimum(ctx: _MatchingContext) -> None:
def verify_optimum(ctx: MatchingContext) -> None:
"""Verify that the optimum solution has been found.
This function takes time O(n**2).

View File

@ -1,5 +1,7 @@
"""Data structures for matching."""
from __future__ import annotations
from typing import Generic, Optional, TypeVar
@ -38,7 +40,7 @@ class UnionFindQueue(Generic[_NameT, _ElemT]):
"parent", "left", "right")
def __init__(self,
owner: "UnionFindQueue[_NameT2, _ElemT2]",
owner: UnionFindQueue[_NameT2, _ElemT2],
data: _ElemT2,
prio: float
) -> None:
@ -47,14 +49,14 @@ class UnionFindQueue(Generic[_NameT, _ElemT]):
This method should not be called directly.
Instead, call UnionFindQueue.insert().
"""
self.owner: "Optional[UnionFindQueue[_NameT2, _ElemT2]]" = owner
self.owner: Optional[UnionFindQueue[_NameT2, _ElemT2]] = owner
self.data = data
self.prio = prio
self.min_node = self
self.height = 1
self.parent: "Optional[UnionFindQueue.Node[_NameT2, _ElemT2]]"
self.left: "Optional[UnionFindQueue.Node[_NameT2, _ElemT2]]"
self.right: "Optional[UnionFindQueue.Node[_NameT2, _ElemT2]]"
self.parent: Optional[UnionFindQueue.Node[_NameT2, _ElemT2]]
self.left: Optional[UnionFindQueue.Node[_NameT2, _ElemT2]]
self.right: Optional[UnionFindQueue.Node[_NameT2, _ElemT2]]
self.parent = None
self.left = None
self.right = None
@ -98,9 +100,9 @@ class UnionFindQueue(Generic[_NameT, _ElemT]):
name: Name to assign to the new queue.
"""
self.name = name
self.tree: "Optional[UnionFindQueue.Node[_NameT, _ElemT]]" = None
self.sub_queues: "list[UnionFindQueue[_NameT, _ElemT]]" = []
self.split_nodes: "list[UnionFindQueue.Node[_NameT, _ElemT]]" = []
self.tree: Optional[UnionFindQueue.Node[_NameT, _ElemT]] = None
self.sub_queues: list[UnionFindQueue[_NameT, _ElemT]] = []
self.split_nodes: list[UnionFindQueue.Node[_NameT, _ElemT]] = []
def clear(self) -> None:
"""Remove all elements from the queue.
@ -165,7 +167,7 @@ class UnionFindQueue(Generic[_NameT, _ElemT]):
return node.min_node.data
def merge(self,
sub_queues: "list[UnionFindQueue[_NameT, _ElemT]]"
sub_queues: list[UnionFindQueue[_NameT, _ElemT]]
) -> None:
"""Merge the specified queues.
@ -712,7 +714,7 @@ class PriorityQueue(Generic[_ElemT]):
def __init__(self) -> None:
"""Initialize an empty queue."""
self.heap: "list[PriorityQueue.Node[_ElemT]]" = []
self.heap: list[PriorityQueue.Node[_ElemT]] = []
def clear(self) -> None:
"""Remove all elements from the queue.

View File

15
python/pyproject.toml Normal file
View File

@ -0,0 +1,15 @@
[build-system]
requires = ["setuptools >= 61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "mwmatching"
version = "3.0"
authors = [{name = "Joris van Rantwijk"}]
requires-python = ">= 3.7"
classifiers = [
"License :: OSI Approved :: MIT License",
"Private :: Do Not Upload"
]

View File

@ -18,7 +18,7 @@ from mwmatching import (maximum_weight_matching,
adjust_weights_for_maximum_cardinality_matching)
def parse_int_or_float(s: str) -> int|float:
def parse_int_or_float(s: str) -> float:
"""Convert a string to integer or float value."""
try:
return int(s)
@ -27,7 +27,7 @@ def parse_int_or_float(s: str) -> int|float:
return float(s)
def read_dimacs_graph(f: TextIO) -> list[tuple[int, int, int|float]]:
def read_dimacs_graph(f: TextIO) -> list[tuple[int, int, float]]:
"""Read a graph in DIMACS edge list format."""
edges: list[tuple[int, int, float]] = []
@ -70,7 +70,7 @@ def read_dimacs_graph(f: TextIO) -> list[tuple[int, int, int|float]]:
return edges
def read_dimacs_graph_file(filename: str) -> list[tuple[int, int, int|float]]:
def read_dimacs_graph_file(filename: str) -> list[tuple[int, int, float]]:
"""Read a graph from file or stdin."""
if filename:
with open(filename, "r", encoding="ascii") as f:
@ -87,11 +87,11 @@ def read_dimacs_graph_file(filename: str) -> list[tuple[int, int, int|float]]:
def read_dimacs_matching(
f: TextIO
) -> tuple[int|float, list[tuple[int, int]]]:
) -> tuple[float, list[tuple[int, int]]]:
"""Read a matching solution in DIMACS format."""
have_weight = False
weight: int|float = 0
weight: float = 0
pairs: list[tuple[int, int]] = []
for line in f:
@ -138,7 +138,7 @@ def read_dimacs_matching(
def read_dimacs_matching_file(
filename: str
) -> tuple[int|float, list[tuple[int, int]]]:
) -> tuple[float, list[tuple[int, int]]]:
"""Read a matching from file."""
with open(filename, "r", encoding="ascii") as f:
try:
@ -149,7 +149,7 @@ def read_dimacs_matching_file(
def write_dimacs_matching(
f: TextIO,
weight: int|float,
weight: float,
pairs: list[tuple[int, int]]
) -> None:
"""Write a matching solution in DIMACS format."""
@ -165,7 +165,7 @@ def write_dimacs_matching(
def write_dimacs_matching_file(
filename: str,
weight: int|float,
weight: float,
pairs: list[tuple[int, int]]
) -> None:
"""Write a matching to file or stdout."""
@ -177,15 +177,15 @@ def write_dimacs_matching_file(
def calc_matching_weight(
edges: list[tuple[int, int, int|float]],
edges: list[tuple[int, int, float]],
pairs: list[tuple[int, int]]
) -> int|float:
) -> float:
"""Verify that the matching is valid and calculate its weight.
Matched pairs are assumed to be in the same order as edges.
"""
weight: int|float = 0
weight: float = 0
edge_pos = 0
for pair in pairs:
@ -267,7 +267,7 @@ def verify_matching(filename: str, maxcard: bool, wfactor: float) -> bool:
edges = read_dimacs_graph_file(filename)
(gold_weight, gold_pairs) = read_dimacs_matching_file(matching_filename)
edges_adj: Sequence[tuple[int, int, int|float]] = edges
edges_adj: Sequence[tuple[int, int, float]] = edges
if wfactor != 1.0:
if wfactor.is_integer():

0
python/tests/__init__.py Normal file
View File

View File

@ -4,10 +4,12 @@ import math
import unittest
from unittest.mock import Mock
import mwmatching
from mwmatching import (
maximum_weight_matching as mwm,
adjust_weights_for_maximum_cardinality_matching as adj)
from mwmatching.algorithm import (
MatchingError, GraphInfo, Blossom, NonTrivialBlossom, MatchingContext,
verify_optimum)
class TestMaximumWeightMatching(unittest.TestCase):
@ -431,10 +433,10 @@ class TestMaximumCardinalityMatching(unittest.TestCase):
class TestGraphInfo(unittest.TestCase):
"""Test _GraphInfo helper class."""
"""Test GraphInfo helper class."""
def test_empty(self):
graph = mwmatching._GraphInfo([])
graph = GraphInfo([])
self.assertEqual(graph.num_vertex, 0)
self.assertEqual(graph.edges, [])
self.assertEqual(graph.adjacent_edges, [])
@ -449,8 +451,8 @@ class TestVerificationFail(unittest.TestCase):
vertex_mate,
vertex_dual_2x,
nontrivial_blossom):
ctx = Mock(spec=mwmatching._MatchingContext)
ctx.graph = mwmatching._GraphInfo(edges)
ctx = Mock(spec=MatchingContext)
ctx.graph = GraphInfo(edges)
ctx.vertex_mate = vertex_mate
ctx.vertex_dual_2x = vertex_dual_2x
ctx.nontrivial_blossom = nontrivial_blossom
@ -463,7 +465,7 @@ class TestVerificationFail(unittest.TestCase):
vertex_mate=[-1, 2, 1],
vertex_dual_2x=[0, 20, 2],
nontrivial_blossom=[])
mwmatching._verify_optimum(ctx)
verify_optimum(ctx)
def test_asymmetric_matching(self):
edges = [(0,1,10), (1,2,11)]
@ -472,8 +474,8 @@ class TestVerificationFail(unittest.TestCase):
vertex_mate=[-1, 2, 0],
vertex_dual_2x=[0, 20, 2],
nontrivial_blossom=[])
with self.assertRaises(mwmatching.MatchingError):
mwmatching._verify_optimum(ctx)
with self.assertRaises(MatchingError):
verify_optimum(ctx)
def test_nonexistent_matched_edge(self):
edges = [(0,1,10), (1,2,11)]
@ -482,8 +484,8 @@ class TestVerificationFail(unittest.TestCase):
vertex_mate=[2, -1, 0],
vertex_dual_2x=[11, 11, 11],
nontrivial_blossom=[])
with self.assertRaises(mwmatching.MatchingError):
mwmatching._verify_optimum(ctx)
with self.assertRaises(MatchingError):
verify_optimum(ctx)
def test_negative_vertex_dual(self):
edges = [(0,1,10), (1,2,11)]
@ -492,8 +494,8 @@ class TestVerificationFail(unittest.TestCase):
vertex_mate=[-1, 2, 1],
vertex_dual_2x=[-2, 22, 0],
nontrivial_blossom=[])
with self.assertRaises(mwmatching.MatchingError):
mwmatching._verify_optimum(ctx)
with self.assertRaises(MatchingError):
verify_optimum(ctx)
def test_unmatched_nonzero_dual(self):
edges = [(0,1,10), (1,2,11)]
@ -502,8 +504,8 @@ class TestVerificationFail(unittest.TestCase):
vertex_mate=[-1, 2, 1],
vertex_dual_2x=[9, 11, 11],
nontrivial_blossom=[])
with self.assertRaises(mwmatching.MatchingError):
mwmatching._verify_optimum(ctx)
with self.assertRaises(MatchingError):
verify_optimum(ctx)
def test_negative_edge_slack(self):
edges = [(0,1,10), (1,2,11)]
@ -512,8 +514,8 @@ class TestVerificationFail(unittest.TestCase):
vertex_mate=[-1, 2, 1],
vertex_dual_2x=[0, 11, 11],
nontrivial_blossom=[])
with self.assertRaises(mwmatching.MatchingError):
mwmatching._verify_optimum(ctx)
with self.assertRaises(MatchingError):
verify_optimum(ctx)
def test_matched_edge_slack(self):
edges = [(0,1,10), (1,2,11)]
@ -522,8 +524,8 @@ class TestVerificationFail(unittest.TestCase):
vertex_mate=[-1, 2, 1],
vertex_dual_2x=[0, 20, 11],
nontrivial_blossom=[])
with self.assertRaises(mwmatching.MatchingError):
mwmatching._verify_optimum(ctx)
with self.assertRaises(MatchingError):
verify_optimum(ctx)
def test_negative_blossom_dual(self):
#
@ -532,11 +534,8 @@ class TestVerificationFail(unittest.TestCase):
# \----8-----/
#
edges = [(0,1,7), (0,2,8), (1,2,9), (2,3,6)]
blossom = mwmatching._NonTrivialBlossom(
subblossoms=[
mwmatching._Blossom(0),
mwmatching._Blossom(1),
mwmatching._Blossom(2)],
blossom = NonTrivialBlossom(
subblossoms=[Blossom(0), Blossom(1), Blossom(2)],
edges=[0,2,1])
for sub in blossom.subblossoms:
sub.parent = blossom
@ -546,8 +545,8 @@ class TestVerificationFail(unittest.TestCase):
vertex_mate=[1, 0, 3, 2],
vertex_dual_2x=[4, 6, 8, 4],
nontrivial_blossom=[blossom])
with self.assertRaises(mwmatching.MatchingError):
mwmatching._verify_optimum(ctx)
with self.assertRaises(MatchingError):
verify_optimum(ctx)
def test_blossom_not_full(self):
#
@ -560,11 +559,8 @@ class TestVerificationFail(unittest.TestCase):
# \----2-----/
#
edges = [(0,1,7), (0,2,2), (1,2,5), (0,3,8), (1,4,8)]
blossom = mwmatching._NonTrivialBlossom(
subblossoms=[
mwmatching._Blossom(0),
mwmatching._Blossom(1),
mwmatching._Blossom(2)],
blossom = NonTrivialBlossom(
subblossoms=[Blossom(0), Blossom(1), Blossom(2)],
edges=[0,2,1])
for sub in blossom.subblossoms:
sub.parent = blossom
@ -574,8 +570,8 @@ class TestVerificationFail(unittest.TestCase):
vertex_mate=[3, 4, -1, 0, 1],
vertex_dual_2x=[4, 10, 0, 12, 6],
nontrivial_blossom=[blossom])
with self.assertRaises(mwmatching.MatchingError):
mwmatching._verify_optimum(ctx)
with self.assertRaises(MatchingError):
verify_optimum(ctx)
if __name__ == "__main__":

View File

@ -3,7 +3,7 @@
import random
import unittest
from datastruct import UnionFindQueue, PriorityQueue
from mwmatching.datastruct import UnionFindQueue, PriorityQueue
class TestUnionFindQueue(unittest.TestCase):

View File

@ -1,31 +0,0 @@
#!/bin/bash
set -e
echo
echo "Running pycodestyle"
pycodestyle python/mwmatching.py python/datastruct.py tests
echo
echo "Running mypy"
mypy --disallow-incomplete-defs python tests
echo
echo "Running pylint"
pylint --ignore=test_mwmatching.py python tests || [ $(($? & 3)) -eq 0 ]
echo
echo "Running test_mwmatching.py"
python3 python/test_mwmatching.py
echo
echo "Running test_datastruct.py"
python3 python/test_datastruct.py
echo
echo "Checking test coverage"
coverage erase
coverage run --branch python/test_datastruct.py
coverage run -a --branch python/test_mwmatching.py
coverage report -m

View File

@ -1,57 +0,0 @@
#!/bin/sh
set -e
echo
echo "Running Python unit tests"
echo
python3 --version
echo
python3 python/test_mwmatching.py
echo
echo "Checking test coverage"
echo
coverage erase
coverage run --branch python/test_mwmatching.py
coverage report -m
echo
echo "Running Python code on test graphs"
echo
python3 python/run_matching.py --verify \
tests/graphs/chain_n1000.edge \
tests/graphs/chain_n5000.edge \
tests/graphs/sparse_delta_n1004.edge \
tests/graphs/triangles_n1002.edge \
tests/graphs/triangles_n5001.edge \
tests/graphs/random_n1000_m10000.edge \
tests/graphs/random_n2000_m10000.edge
echo
echo "Running C++ unit tests"
echo
g++ --version
echo
make -C cpp run_matching test_mwmatching
cpp/test_mwmatching
echo
echo "Running C++ code on test graphs"
echo
python3 tests/run_test.py --solver cpp/run_matching --verify \
tests/graphs/chain_n1000.edge \
tests/graphs/chain_n5000.edge \
tests/graphs/sparse_delta_n1004.edge \
tests/graphs/triangles_n1002.edge \
tests/graphs/triangles_n5001.edge \
tests/graphs/random_n1000_m10000.edge \
tests/graphs/random_n2000_m10000.edge

35
test_cpp.sh Executable file
View File

@ -0,0 +1,35 @@
#!/bin/sh
set -e
echo
echo ">> Running unit tests"
echo
g++ --version
echo
make -C cpp run_matching test_mwmatching
cpp/test_mwmatching
echo
echo ">> Running test graphs"
echo
python3 tests/run_test.py --solver cpp/run_matching --verify \
tests/graphs/chain_n1000.edge \
tests/graphs/chain_n5000.edge \
tests/graphs/chain_n10000.edge \
tests/graphs/sparse_delta_n1004.edge \
tests/graphs/sparse_delta_n2004.edge \
tests/graphs/sparse_delta_n5004.edge \
tests/graphs/triangles_n1002.edge \
tests/graphs/triangles_n5001.edge \
tests/graphs/triangles_n10002.edge \
tests/graphs/random_n1000_m10000.edge \
tests/graphs/random_n2000_m10000.edge \
tests/graphs/random_n4000_m10000.edge
echo
echo ">> Done"

47
test_python.sh Executable file
View File

@ -0,0 +1,47 @@
#!/bin/bash
set -e
python3 --version
echo
echo ">> Running pycodestyle"
pycodestyle python/mwmatching python/run_matching.py tests
echo
echo ">> Running mypy"
mypy --disallow-incomplete-defs python tests
echo
echo ">> Running pylint"
pylint --ignore=test_algorithm.py python tests/*.py tests/generate/*.py || [ $(($? & 3)) -eq 0 ]
echo
echo ">> Running unit tests"
python3 -m unittest discover -t python -s python/tests
echo
echo ">> Checking test coverage"
coverage erase
coverage run --branch -m unittest discover -t python -s python/tests
coverage report -m
echo
echo ">> Running test graphs"
python3 python/run_matching.py --verify \
tests/graphs/chain_n1000.edge \
tests/graphs/chain_n5000.edge \
tests/graphs/chain_n10000.edge \
tests/graphs/sparse_delta_n1004.edge \
tests/graphs/sparse_delta_n2004.edge \
tests/graphs/sparse_delta_n5004.edge \
tests/graphs/triangles_n1002.edge \
tests/graphs/triangles_n5001.edge \
tests/graphs/triangles_n10002.edge \
tests/graphs/random_n1000_m10000.edge \
tests/graphs/random_n2000_m10000.edge \
tests/graphs/random_n4000_m10000.edge
echo
echo ">> Done"

View File

@ -19,13 +19,12 @@ count_delta_step = [0]
def patch_matching_code() -> None:
"""Patch the matching code to count events."""
# pylint: disable=import-outside-toplevel,protected-access
# pylint: disable=import-outside-toplevel
import mwmatching
from mwmatching.algorithm import MatchingContext
orig_make_blossom = mwmatching._MatchingContext.make_blossom
orig_calc_dual_delta_step = (
mwmatching._MatchingContext.calc_dual_delta_step)
orig_make_blossom = MatchingContext.make_blossom
orig_calc_dual_delta_step = MatchingContext.calc_dual_delta_step
def stub_make_blossom(*args: Any, **kwargs: Any) -> Any:
count_make_blossom[0] += 1
@ -36,9 +35,8 @@ def patch_matching_code() -> None:
ret = orig_calc_dual_delta_step(*args, **kwargs)
return ret
mwmatching._MatchingContext.make_blossom = ( # type: ignore
stub_make_blossom)
mwmatching._MatchingContext.calc_dual_delta_step = ( # type: ignore
MatchingContext.make_blossom = stub_make_blossom # type: ignore
MatchingContext.calc_dual_delta_step = ( # type: ignore
stub_calc_dual_delta_step)

51
tests/run_benchmark.sh Executable file
View File

@ -0,0 +1,51 @@
#!/bin/bash
PYMWM=../python/run_matching.py
CPPMWM=../cpp/run_matching
LEMON=lemon/lemon_matching
# Generate big graphs that are not in the Git repo.
python3 generate/make_slow_graph.py chain 20000 > graphs/chain_n20000.edge
python3 generate/make_slow_graph.py chain 50000 > graphs/chain_n50000.edge
python3 generate/triangles.py 16667 1 > graphs/triangles_n50001.edge
# Run pre-generated graphs.
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --runs 5 --verify \
graphs/chain_n1000.edge \
graphs/chain_n5000.edge \
graphs/chain_n10000.edge \
graphs/sparse_delta_n1004.edge \
graphs/sparse_delta_n2004.edge \
graphs/sparse_delta_n5004.edge \
graphs/triangles_n1002.edge \
graphs/triangles_n5001.edge \
graphs/triangles_n10002.edge
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --runs 5 \
graphs/chain_n20000.edge \
graphs/chain_n50000.edge \
graphs/triangles_n50001.edge
# Run random graphs.
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 1000 --m 5000
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 1000 --m 31622
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 1000 --m 499500
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 2000 --m 10000
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 2000 --m 89442
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 2000 --m 1999000
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 5000 --m 25000
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 5000 --m 353553
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 10000 --m 50000
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 10000 --m 1000000
./run_test.py --solver $PYMWM --solver $CPPMWM --solver $LEMON --timeout 600 --random --runs 25 --n 20000 --m 100000

50
tox.ini Normal file
View File

@ -0,0 +1,50 @@
[tox]
skipsdist = true
env_list =
static
coverage
py37
py38
py39
py310
py311
py312
pypy3
[testenv]
commands =
python --version
python -m unittest discover -t python -s python/tests
python python/run_matching.py --verify \
tests/graphs/chain_n1000.edge \
tests/graphs/chain_n5000.edge \
tests/graphs/chain_n10000.edge \
tests/graphs/sparse_delta_n1004.edge \
tests/graphs/sparse_delta_n2004.edge \
tests/graphs/sparse_delta_n5004.edge \
tests/graphs/triangles_n1002.edge \
tests/graphs/triangles_n5001.edge \
tests/graphs/triangles_n10002.edge \
tests/graphs/random_n1000_m10000.edge \
tests/graphs/random_n2000_m10000.edge \
tests/graphs/random_n4000_m10000.edge
[testenv:static]
deps =
mypy
pycodestyle
pylint
commands =
pycodestyle python/mwmatching python/run_matching.py tests
mypy --disallow-incomplete-defs python tests
pylint --ignore=test_algorithm.py python tests/*.py tests/generate/*.py
[testenv:coverage]
deps =
coverage
commands =
coverage erase
coverage run --branch -m unittest discover -t python -s python/tests
coverage report -m