diff --git a/src/doc/en/reference/graphs/index.rst b/src/doc/en/reference/graphs/index.rst index 861b99796b9..27eeba5045b 100644 --- a/src/doc/en/reference/graphs/index.rst +++ b/src/doc/en/reference/graphs/index.rst @@ -90,6 +90,7 @@ Libraries of algorithms sage/graphs/pq_trees sage/graphs/matching sage/graphs/matchpoly + sage/graphs/morphisms sage/graphs/genus sage/graphs/lovasz_theta sage/graphs/schnyder @@ -126,5 +127,3 @@ Libraries of algorithms sage/graphs/cycle_enumeration .. include:: ../footer.txt - - \ No newline at end of file diff --git a/src/sage/graphs/generic_graph.py b/src/sage/graphs/generic_graph.py index 847ce71bf05..7dd9dc062a6 100644 --- a/src/sage/graphs/generic_graph.py +++ b/src/sage/graphs/generic_graph.py @@ -168,6 +168,9 @@ :meth:`~GenericGraph.is_isomorphic` | Test for isomorphism between ``self`` and ``other``. :meth:`~GenericGraph.canonical_label` | Return the canonical graph. :meth:`~GenericGraph.is_cayley` | Check whether the graph is a Cayley graph. + :meth:`~GenericGraph.is_homeomorphic` | Check whether ``G`` and ``H`` are homeomorphic. + :meth:`~GenericGraph.reduced_homeomorphic_graph` | Return the smallest graph homeomorphic to `G`. + :meth:`~GenericGraph.has_homomorphism_to` | Check whether there is a homomorphism between two graphs. **Graph properties:** @@ -26016,6 +26019,11 @@ def is_self_complementary(self): ) from sage.graphs.line_graph import line_graph rooted_product = LazyImport('sage.graphs.graph_decompositions.graph_products', 'rooted_product') + from sage.graphs.morphisms import ( + has_homomorphism_to, + is_homeomorphic, + reduced_homeomorphic_graph, + ) from sage.graphs.path_enumeration import ( _all_paths_iterator, all_paths, diff --git a/src/sage/graphs/graph.py b/src/sage/graphs/graph.py index 248fd65d12f..871d182cbad 100644 --- a/src/sage/graphs/graph.py +++ b/src/sage/graphs/graph.py @@ -3966,119 +3966,6 @@ def mono(pi): ret += prod(fact[i] for i in pa.to_exp()) * m[pa] * (1+t)**mono(pi) return ret - @doc_index("Algorithmically hard stuff") - def has_homomorphism_to(self, H, core=False, solver=None, verbose=0, - *, integrality_tolerance=1e-3): - r""" - Check whether there is a homomorphism between two graphs. - - A homomorphism from a graph `G` to a graph `H` is a function - `\phi:V(G)\mapsto V(H)` such that for any edge `uv \in E(G)` the pair - `\phi(u)\phi(v)` is an edge of `H`. - - Saying that a graph can be `k`-colored is equivalent to saying that it - has a homomorphism to `K_k`, the complete graph on `k` elements. - - For more information, see the :wikipedia:`Graph_homomorphism`. - - INPUT: - - - ``H`` -- the graph to which ``self`` should be sent - - - ``core`` -- boolean (default: ``False``; whether to minimize the size - of the mapping's image (see note below). This is set to ``False`` by - default. - - - ``solver`` -- string (default: ``None``); specifies a Mixed Integer - Linear Programming (MILP) solver to be used. If set to ``None``, the - default one is used. For more information on MILP solvers and which - default solver is used, see the method :meth:`solve - ` of the class - :class:`MixedIntegerLinearProgram - `. - - - ``verbose`` -- integer (default: 0); sets the level of - verbosity. Set to 0 by default, which means quiet. - - - ``integrality_tolerance`` -- float; parameter for use with MILP - solvers over an inexact base ring; see - :meth:`MixedIntegerLinearProgram.get_values`. - - .. NOTE:: - - One can compute the core of a graph (with respect to homomorphism) - with this method :: - - sage: g = graphs.CycleGraph(10) - sage: mapping = g.has_homomorphism_to(g, core=True) # needs sage.numerical.mip - sage: print("The size of the core is {}".format(len(set(mapping.values())))) # needs sage.numerical.mip - The size of the core is 2 - - OUTPUT: - - This method returns ``False`` when the homomorphism does not exist, and - returns the homomorphism otherwise as a dictionary associating a vertex - of `H` to a vertex of `G`. - - EXAMPLES: - - Is Petersen's graph 3-colorable:: - - sage: P = graphs.PetersenGraph() - sage: P.has_homomorphism_to(graphs.CompleteGraph(3)) is not False # needs sage.numerical.mip - True - - An odd cycle admits a homomorphism to a smaller odd cycle, but not to an - even cycle:: - - sage: g = graphs.CycleGraph(9) - sage: g.has_homomorphism_to(graphs.CycleGraph(5)) is not False # needs sage.numerical.mip - True - sage: g.has_homomorphism_to(graphs.CycleGraph(7)) is not False # needs sage.numerical.mip - True - sage: g.has_homomorphism_to(graphs.CycleGraph(4)) is not False # needs sage.numerical.mip - False - """ - self._scream_if_not_simple() - from sage.numerical.mip import MixedIntegerLinearProgram, MIPSolverException - p = MixedIntegerLinearProgram(solver=solver, maximization=False) - b = p.new_variable(binary=True) - - # Each vertex has an image - for ug in self: - p.add_constraint(p.sum(b[ug, uh] for uh in H) == 1) - - nonedges = H.complement().edges(sort=False, labels=False) - for ug, vg in self.edges(sort=False, labels=False): - # Two adjacent vertices cannot be mapped to the same element - for uh in H: - p.add_constraint(b[ug, uh] + b[vg, uh] <= 1) - - # Two adjacent vertices cannot be mapped to no adjacent vertices - for uh, vh in nonedges: - p.add_constraint(b[ug, uh] + b[vg, vh] <= 1) - p.add_constraint(b[ug, vh] + b[vg, uh] <= 1) - - # Minimize the mapping's size - if core: - - # the value of m is one if the corresponding vertex of h is used. - m = p.new_variable(nonnegative=True) - for uh in H: - for ug in self: - p.add_constraint(b[ug, uh] <= m[uh]) - - p.set_objective(p.sum(m[vh] for vh in H)) - - try: - p.solve(log=verbose) - except MIPSolverException: - return False - - b = p.get_values(b, convert=bool, tolerance=integrality_tolerance) - mapping = dict(x[0] for x in b.items() if x[1]) - return mapping - @doc_index("Clique-related methods") def fractional_clique_number(self, solver='PPL', verbose=0, check_components=True, check_bipartite=True): diff --git a/src/sage/graphs/meson.build b/src/sage/graphs/meson.build index 47d8544d1dd..c903afd1f0e 100644 --- a/src/sage/graphs/meson.build +++ b/src/sage/graphs/meson.build @@ -74,6 +74,7 @@ py.install_sources( 'matchpoly.pyx', 'mcqd.pxd', 'mcqd.pyx', + 'morphisms.py', 'orientations.py', 'partial_cube.py', 'path_enumeration.pyx', diff --git a/src/sage/graphs/morphisms.py b/src/sage/graphs/morphisms.py new file mode 100644 index 00000000000..b79412d20b2 --- /dev/null +++ b/src/sage/graphs/morphisms.py @@ -0,0 +1,462 @@ +r""" +Morphisms + +This module gathers methods related to homeomorphims, homomorphisms, +isomorphisms, etc. in (di)graphs. + +**This module contains the following methods** + +.. csv-table:: + :class: contentstable + :widths: 30, 70 + :delim: | + + :meth:`~is_homeomorphic` | Check whether ``G`` and ``H`` are homeomorphic. + :meth:`~reduced_homeomorphic_graph` | Return the smallest graph homeomorphic to ``G``. + :meth:`~has_homomorphism_to` | Check whether there is a homomorphism between two graphs. + +.. TODO:: + + - Move methods related to graph automorphisms to this module + - Move methods related to graph isomorphisms to this module + +Methods +------- +""" +# **************************************************************************** +# Copyright (C) 2025 David Coudert +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# https://www.gnu.org/licenses/ +# **************************************************************************** + + +def reduced_homeomorphic_graph(G, allow_multiple_edges=False, allow_loops=False, + return_steps=False, immutable=None): + r""" + Return the smallest graph homeomorphic to `G`. + + Two graphs `G` and `H` are homeomorphic if there is an isomorphism from some + subdivision of `G` to some subdivision of `H`. For more details, see the + :wikipedia:`Homeomorphism_(graph_theory)`. + + By default (i.e., when ``allow_multiple_edges == False`` and ``allow_loops + == False``), given a graph `G`, a vertex `u` of degree two and its neighbors + `x` and `y`, with `x \neq y`, this methods replaces the path `(x, u, y)` + with the edge `(x, y)` unless the graph already has edge `(x, y)`. This + process is repeated for each vertex of degree two. The resulting graph `H` + is the smallest graph that is homeomorphic to `G`. + + When ``allow_multiple_edges == True`` and ``allow_loops == False``, this + method always replaces the path `(x, u, y)` with a new edge `(x, y)`. Hence, + the resulting graph may have several edges between `x` and `y`. This + operation is performed only if `x \neq y`. + + When ``allow_loops == True``, this method also assumes that + ``allow_multiple_edges == True``. If a vertex `u` of degree two is connected + by two edges to a vertex `x`, this method replaces the two edges by a loop + edge on `x`. + + For digraphs, the method considers the vertices with in and out degree one. + + INPUT: + + - ``G`` -- a graph or a digraph + + - ``allow_multiple_edges`` -- boolean (default: ``False``); whether to allow + the creation of new multiple edges. This parameter is considered ``True`` + when ``allow_loops`` is ``True``. + + - ``allow_loops`` -- boolean (default: ``False``); whether to allow the + creation of new loops + + - ``return_steps`` -- boolean (default: ``False``); whether to return the + steps of the reduction as a list of triples `(x, u, y)` indicating that + path `(x, u, y)` has been replaced by edge `(x, y)`. The original graph + can be reconstructed by using this list in reverse order. + + - ``immutable`` -- boolean (default: ``None``); whether to create a + mutable/immutable (di)graph. ``immutable=None`` (default) means that the + (di)graph and its reduced (di)graph will behave the same way. + + OUTPUT: When ``return_steps`` is ``False``, this method returns the reduced + graph. When ``return_steps`` is ``True``, this method returns both the + reduced graph and the ordered list of reduction operations. Each reduction + operation is a triple `(x, u, y)` indicating that the path `(x, u, y)`, with + `u` of degree two (or with in and out degree one for digraphs), has been + replaced by edge `(x, y)`. + + EXAMPLES: + + Reduction of a Cycle Graph:: + + sage: G = graphs.CycleGraph(4) + sage: G.reduced_homeomorphic_graph() + Graph on 3 vertices + sage: G.reduced_homeomorphic_graph(allow_multiple_edges=True) + Multi-graph on 2 vertices + sage: G.reduced_homeomorphic_graph(allow_loops=True) + Looped multi-graph on 1 vertex + + Reduction of a Circuit:: + + sage: G = digraphs.Circuit(4) + sage: G.reduced_homeomorphic_graph() + Digraph on 2 vertices + sage: G.reduced_homeomorphic_graph(allow_multiple_edges=True) + Multi-digraph on 2 vertices + sage: G.reduced_homeomorphic_graph(allow_loops=True) + Looped multi-digraph on 1 vertex + + Check that the construction is reversible:: + + sage: def revert_steps(g, steps): + ....: h = g.copy(immutable=False) + ....: for P in reversed(steps): + ....: h.add_path(P) + ....: h.delete_edge(P[0], P[2]) + ....: return h + + sage: G = graphs.WindmillGraph(3, 5) + sage: G.order(), G.size() + (11, 15) + sage: H, steps = G.reduced_homeomorphic_graph(return_steps=True) + sage: H.order(), H.size() + (11, 15) + sage: G.is_isomorphic(revert_steps(H, steps)) + True + sage: H, steps = G.reduced_homeomorphic_graph(allow_multiple_edges=True, return_steps=True) + sage: H.order(), H.size() + (6, 10) + sage: G.is_isomorphic(revert_steps(H, steps)) + True + sage: H, steps = G.reduced_homeomorphic_graph(allow_loops=True, return_steps=True) + sage: H.order(), H.size() + (1, 5) + sage: len(H.loop_edges()) + 5 + sage: G.is_isomorphic(revert_steps(H, steps)) + True + + Random digraph:: + + sage: G = digraphs.RandomDirectedGNP(20, 0.05, loops=True) + sage: H, steps = G.reduced_homeomorphic_graph(return_steps=True) + sage: G.is_isomorphic(revert_steps(H, steps)) + True + sage: H, steps = G.reduced_homeomorphic_graph(allow_multiple_edges=True, return_steps=True) + sage: G.is_isomorphic(revert_steps(H, steps)) + True + sage: H, steps = G.reduced_homeomorphic_graph(allow_loops=True, return_steps=True) + sage: G.is_isomorphic(revert_steps(H, steps)) + True + + TESTS: + + Check the behavior of parameter ``immutable``:: + + sage: G = graphs.CycleGraph(3) + sage: G.reduced_homeomorphic_graph().is_immutable() + False + sage: G.reduced_homeomorphic_graph(immutable=True).is_immutable() + True + sage: G = G.copy(immutable=True) + sage: G.reduced_homeomorphic_graph().is_immutable() + True + sage: G.reduced_homeomorphic_graph(immutable=False).is_immutable() + False + """ + if allow_loops: + allow_multiple_edges = True + + if G.is_directed(): + from sage.graphs.digraph import DiGraph as MyGraph + + # candidates is the list of vertices with in and out degree 1 + out_degree_one = (u for u, d in G.out_degree_iterator(labels=True) if d == 1) + candidates = (u for u, d in G.in_degree_iterator(vertices=out_degree_one, labels=True) if d == 1) + + def get_neighbors(g, u): + return (next(g.neighbor_in_iterator(u)), + next(g.neighbor_out_iterator(u))) + + else: + from sage.graphs.graph import Graph as MyGraph + + # candidates is the list of vertices with degree 2 + candidates = (u for u, d in G.degree_iterator(labels=True) if d == 2) + + if allow_multiple_edges: + + def get_neighbors(g, u): + N = g.neighbors(u) + if len(N) == 1: + return N * 2 + return N + + else: + + def get_neighbors(g, u): + return g.neighbors(u) + + # Copy of the (di)graph with required settings for loops and multiple edges + H = MyGraph([G, G.edge_iterator(labels=False)], format='vertices_and_edges', + multiedges=G.allows_multiple_edges() or allow_multiple_edges, + loops=G.allows_loops() or allow_loops, immutable=False) + + steps = [] + for u in candidates: + x, y = get_neighbors(H, u) + if (not allow_loops and x == y) or x == u: + # The case x = u = y may occur when contracting a cycle + continue + if not allow_multiple_edges and H.has_edge(x, y): + continue + # Replace path (x, u, y) with edge (x, y) + H.delete_vertex(u) + H.add_edge(x, y) + steps.append((x, u, y)) + + if immutable is None: + immutable = G.is_immutable() + if immutable: + H = H.copy(immutable=True) + + if return_steps: + return H, steps + return H + + +def is_homeomorphic(G, H): + r""" + Check whether ``G`` and ``H`` are homeomorphic. + + Two graphs `G` and `H` are homeomorphic if there is an isomorphism from some + subdivision of `G` to some subdivision of `H`. To check whether `G` and `H` + are homeomorphic, it suffices to check whether their reduced homeomorphic + (di)graphs are isomorphic. For more details, see the + :wikipedia:`Homeomorphism_(graph_theory)`. + + INPUT: + + - ``G``, ``H`` -- two (di)graphs + + EXAMPLES:: + + sage: G = graphs.RandomGNP(10, .2) + sage: H = G.copy() + sage: for e in list(G.edges()): + ....: G.subdivide_edge(e, randint(0, 5)) + ....: H.subdivide_edge(e, randint(0, 5)) + sage: G.is_homeomorphic(H) + True + sage: G = graphs.RandomGNP(10, .2) + sage: G.allow_multiple_edges(True) + sage: G.add_edges(G.edges()) + sage: H = G.copy() + sage: for e in list(G.edges()): + ....: G.subdivide_edge(e, randint(0, 5)) + ....: H.subdivide_edge(e, randint(0, 5)) + sage: G.is_homeomorphic(H) + True + + sage: G = digraphs.RandomDirectedGNP(10, .2) + sage: H = G.copy() + sage: for e in list(G.edges()): + ....: G.subdivide_edge(e, randint(0, 5)) + ....: H.subdivide_edge(e, randint(0, 5)) + sage: G.is_homeomorphic(H) + True + sage: G = digraphs.RandomDirectedGNP(10, .2) + sage: G.allow_multiple_edges(True) + sage: G.add_edges(G.edges()) + sage: H = G.copy() + sage: for e in list(G.edges()): + ....: G.subdivide_edge(e, randint(0, 5)) + ....: H.subdivide_edge(e, randint(0, 5)) + sage: G.is_homeomorphic(H) + True + + sage: G = digraphs.RandomDirectedGNP(10, .2) + sage: G.allow_loops(True) + sage: G.add_edges((u, u) for u in G if randint(0, 1)) + sage: G.allow_multiple_edges(True) + sage: G.add_edges(G.edges()) + sage: H = G.copy() + sage: for e in list(G.edges()): + ....: G.subdivide_edge(e, randint(0, 5)) + ....: H.subdivide_edge(e, randint(0, 5)) + sage: G.is_homeomorphic(H) + True + + TESTS:: + + sage: Graph(1).is_homeomorphic(DiGraph(1)) + False + """ + if G.is_directed() is not H.is_directed(): + return False + X = G.reduced_homeomorphic_graph(allow_loops=True, immutable=False) + Y = H.reduced_homeomorphic_graph(allow_loops=True, immutable=False) + return X.is_isomorphic(Y) + + +def has_homomorphism_to(G, H, core=False, solver=None, verbose=0, + *, integrality_tolerance=1e-3): + r""" + Check whether there is a homomorphism between two graphs. + + A homomorphism from a graph `G` to a graph `H` is a function + `\phi:V(G)\mapsto V(H)` such that for any edge `uv \in E(G)` the pair + `\phi(u)\phi(v)` is an edge of `H`. + + Saying that a graph can be `k`-colored is equivalent to saying that it has a + homomorphism to `K_k`, the complete graph of order `k`. + + For more information, see the :wikipedia:`Graph_homomorphism`. + + INPUT: + + - ``G`` -- the graph to map + + - ``H`` -- the graph to which ``G`` should be sent + + - ``core`` -- boolean (default: ``False``); whether to minimize the size of + the mapping's image (see examples below). This is set to ``False`` by + default. + + - ``solver`` -- string (default: ``None``); specifies a Mixed Integer Linear + Programming (MILP) solver to be used. If set to ``None``, the default one + is used. For more information on MILP solvers and which default solver is + used, see the method :meth:`solve + ` of the class + :class:`MixedIntegerLinearProgram + `. + + - ``verbose`` -- integer (default: 0); sets the level of verbosity. Set to 0 + by default, which means quiet. + + - ``integrality_tolerance`` -- float; parameter for use with MILP solvers + over an inexact base ring; see + :meth:`MixedIntegerLinearProgram.get_values`. + + OUTPUT: + + This method returns ``False`` when the homomorphism does not exist, and + returns the homomorphism otherwise as a dictionary associating a vertex of + `H` to a vertex of `G`. + + EXAMPLES: + + Is Petersen's graph 3-colorable:: + + sage: P = graphs.PetersenGraph() + sage: P.has_homomorphism_to(graphs.CompleteGraph(3)) is not False # needs sage.numerical.mip + True + + An odd cycle admits a homomorphism to a smaller odd cycle, but not to an + even cycle:: + + sage: g = graphs.CycleGraph(9) + sage: g.has_homomorphism_to(graphs.CycleGraph(5)) is not False # needs sage.numerical.mip + True + sage: g.has_homomorphism_to(graphs.CycleGraph(7)) is not False # needs sage.numerical.mip + True + sage: g.has_homomorphism_to(graphs.CycleGraph(4)) is not False # needs sage.numerical.mip + False + + One can compute the core of a graph (with respect to homomorphism) + with this method:: + + sage: # needs sage.numerical.mip + sage: g = graphs.CycleGraph(8) + sage: mapping = g.has_homomorphism_to(g, core=True) + sage: print(f"The size of the core is {len(set(mapping.values()))}") + The size of the core is 2 + sage: g = graphs.CycleGraph(9) + sage: mapping = g.has_homomorphism_to(g, core=True) + sage: print(f"The size of the core is {len(set(mapping.values()))}") + The size of the core is 9 + + The chromatic number of a graph is the order of the smallest clique to which + it has an homomorphism:: + + sage: # needs sage.numerical.mip + sage: g = graphs.CycleGraph(9) + sage: g.chromatic_number() + 3 + sage: g.has_homomorphism_to(graphs.CompleteGraph(3)) is not False + True + sage: g.has_homomorphism_to(graphs.CompleteGraph(2)) is not False + False + sage: K6 = graphs.CompleteGraph(6) + sage: g.has_homomorphism_to(K6) is not False + True + sage: mapping = g.has_homomorphism_to(K6, core=True) + sage: print(f"The size of the core is {len(set(mapping.values()))}") + The size of the core is 3 + + A circuit of order `n` admits a homomorphism to smaller circuit of order `p + \leq n` if `p` is a divisor of `n`:: + + sage: g = digraphs.Circuit(12) + sage: [i for i in range(2, g.order() + 1) # needs sage.numerical.mip + ....: if g.has_homomorphism_to(digraphs.Circuit(i)) is not False] + [2, 3, 4, 6, 12] + + TESTS:: + + sage: Graph(1).has_homomorphism_to(DiGraph(1)) + False + """ + G._scream_if_not_simple() + H._scream_if_not_simple() + if G.is_directed() is not H.is_directed(): + return False + undirected = not G.is_directed() + + from sage.numerical.mip import MixedIntegerLinearProgram, MIPSolverException + p = MixedIntegerLinearProgram(solver=solver, maximization=False) + b = p.new_variable(binary=True) + + # Each vertex has an image + for ug in G: + p.add_constraint(p.sum(b[ug, uh] for uh in H) == 1) + + nonedges = H.complement().edges(sort=False, labels=False) + for ug, vg in G.edges(sort=False, labels=False): + # Two adjacent vertices cannot be mapped to the same element + for uh in H: + p.add_constraint(b[ug, uh] + b[vg, uh] <= 1) + + # Two adjacent vertices cannot be mapped to no adjacent vertices + for uh, vh in nonedges: + p.add_constraint(b[ug, uh] + b[vg, vh] <= 1) + + if undirected: + # Both directions of edges must be considered for undirected graphs + for uh, vh in nonedges: + p.add_constraint(b[ug, vh] + b[vg, uh] <= 1) + + # Minimize the mapping's size + if core: + + # The value of m is one if the corresponding vertex of H is used + m = p.new_variable(nonnegative=True) + for uh in H: + for ug in G: + p.add_constraint(b[ug, uh] <= m[uh]) + + # Minimize the number of used vertices of H + p.set_objective(p.sum(m[vh] for vh in H)) + + try: + p.solve(log=verbose) + except MIPSolverException: + return False + + b = p.get_values(b, convert=bool, tolerance=integrality_tolerance) + return dict(x[0] for x in b.items() if x[1])