Skip to content

Commit e2aef38

Browse files
author
Release Manager
committed
gh-37028: add method to compute the longest (induced) cycle in a (di)graph This PR adds a method to compute the longest (induced) cycle in a (di)graph. The method can also consider weighted cases. This answers a request from https://ask.sagemath.org/question/75124/how- to-find-a-longest-cycle-and-a-longest-induced-cycle-in-a-graph/ ### 📝 Checklist <!-- Put an `x` in all the boxes that apply. --> <!-- If your change requires a documentation PR, please link it appropriately --> <!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! --> <!-- Feel free to remove irrelevant items. --> - [x] The title is concise, informative, and self-explanatory. - [x] The description explains in detail what this PR is about. - [x] I have linked a relevant issue or discussion. - [x] I have created tests covering the changes. - [x] I have updated the documentation accordingly. ### ⌛ Dependencies <!-- List all open PRs that this PR logically depends on - #12345: short description why this is a dependency - #34567: ... --> <!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! --> URL: #37028 Reported by: David Coudert Reviewer(s): David Coudert, Travis Scrimshaw
2 parents e2f1cb3 + 8b71477 commit e2aef38

File tree

2 files changed

+313
-0
lines changed

2 files changed

+313
-0
lines changed

src/doc/en/reference/references/index.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4717,6 +4717,12 @@ REFERENCES:
47174717
.. [MM2015] \J. Matherne and \G. Muller, *Computing upper cluster algebras*,
47184718
Int. Math. Res. Not. IMRN, 2015, 3121-3149.
47194719
4720+
.. [MMRS2022] Ruslan G. Marzo, Rafael A. Melo, Celso C. Ribeiro and
4721+
Marcio C. Santos: *New formulations and branch-and-cut procedures
4722+
for the longest induced path problem*. Computers & Operations
4723+
Research. 139, 105627 (2022)
4724+
:doi:`10.1016/j.cor.2021.105627`
4725+
47204726
.. [Moh1988] \B. Mohar, *Isoperimetric inequalities, growth, and the spectrum
47214727
of graphs*, Linear Algebra and its Applications 103 (1988),
47224728
119–131.

src/sage/graphs/generic_graph.py

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@
306306
:meth:`~GenericGraph.feedback_vertex_set` | Compute the minimum feedback vertex set of a (di)graph.
307307
:meth:`~GenericGraph.multiway_cut` | Return a minimum edge multiway cut
308308
:meth:`~GenericGraph.max_cut` | Return a maximum edge cut of the graph.
309+
:meth:`~GenericGraph.longest_cycle` | Return the longest (induced) cycle of ``self``.
309310
:meth:`~GenericGraph.longest_path` | Return a longest path of ``self``.
310311
:meth:`~GenericGraph.traveling_salesman_problem` | Solve the traveling salesman problem (TSP)
311312
:meth:`~GenericGraph.is_hamiltonian` | Test whether the current graph is Hamiltonian.
@@ -7874,6 +7875,312 @@ def good_edge(e):
78747875

78757876
return val
78767877

7878+
def longest_cycle(self, induced=False, use_edge_labels=False,
7879+
solver=None, verbose=0, *, integrality_tolerance=0.001):
7880+
r"""
7881+
Return the longest (induced) cycle of ``self``.
7882+
7883+
This method uses an integer linear programming formulation based on
7884+
subtour elimination constraints to find the longest cycle. This cycle is
7885+
*elementary* (or *simple*), and so without repeated vertices. When
7886+
searching for an *induced* cycle (i.e., a cycle without chord), it uses
7887+
in addition cycle elimination constraints as proposed in [MMRS2022]_.
7888+
7889+
We assume that the longest cycle in graph has at least 3 vertices and at
7890+
least 2 in a digraph. The longest induced cycle as at least 4 vertices
7891+
in both a graph and a digraph.
7892+
7893+
.. NOTE::
7894+
7895+
Graphs and digraphs with loops or multiple edges are currently not
7896+
accepted. It is certainly possible to extend the method to accept
7897+
them.
7898+
7899+
INPUT:
7900+
7901+
- ``induced`` -- boolean (default: ``False``); whether to return the
7902+
longest induced cycle or the longest cycle
7903+
7904+
- ``use_edge_labels`` -- boolean (default: ``False``); whether to
7905+
compute a cycle with maximum weight where the weight of an edge is
7906+
defined by its label (a label set to ``None`` or ``{}`` being
7907+
considered as a weight of `1`), or to compute a cycle with the largest
7908+
possible number of edges (i.e., edge weights are set to 1)
7909+
7910+
- ``solver`` -- string (default: ``None``); specify a Mixed Integer
7911+
Linear Programming (MILP) solver to be used. If set to ``None``, the
7912+
default one is used. For more information on MILP solvers and which
7913+
default solver is used, see the method :meth:`solve
7914+
<sage.numerical.mip.MixedIntegerLinearProgram.solve>` of the class
7915+
:class:`MixedIntegerLinearProgram
7916+
<sage.numerical.mip.MixedIntegerLinearProgram>`.
7917+
7918+
- ``verbose`` -- integer (default: ``0``); sets the level of
7919+
verbosity; set to ``0`` by default, which means quiet
7920+
7921+
- ``integrality_tolerance`` -- float; parameter for use with MILP
7922+
solvers over an inexact base ring; see
7923+
:meth:`MixedIntegerLinearProgram.get_values`
7924+
7925+
OUTPUT:
7926+
7927+
A subgraph of ``self`` corresponding to a (directed if ``self`` is
7928+
directed) longest (induced) cycle. If ``use_edge_labels == True``, a
7929+
pair ``weight, cycle`` is returned.
7930+
7931+
EXAMPLES:
7932+
7933+
Longest (induced) cycle of a graph::
7934+
7935+
sage: G = graphs.Grid2dGraph(3, 4)
7936+
sage: G.longest_cycle(induced=False)
7937+
longest cycle from 2D Grid Graph for [3, 4]: Graph on 12 vertices
7938+
sage: G.longest_cycle(induced=True)
7939+
longest induced cycle from 2D Grid Graph for [3, 4]: Graph on 10 vertices
7940+
7941+
Longest (induced) cycle in a digraph::
7942+
7943+
sage: D = digraphs.Circuit(8)
7944+
sage: D.add_edge(0, 2)
7945+
sage: D.longest_cycle(induced=False)
7946+
longest cycle from Circuit: Digraph on 8 vertices
7947+
sage: D.longest_cycle(induced=True)
7948+
longest induced cycle from Circuit: Digraph on 7 vertices
7949+
sage: D.add_edge(1, 0)
7950+
sage: D.longest_cycle(induced=False)
7951+
longest cycle from Circuit: Digraph on 8 vertices
7952+
sage: D.longest_cycle(induced=True)
7953+
longest induced cycle from Circuit: Digraph on 7 vertices
7954+
sage: D.add_edge(2, 0)
7955+
sage: D.longest_cycle(induced=False)
7956+
longest cycle from Circuit: Digraph on 8 vertices
7957+
sage: D.longest_cycle(induced=True)
7958+
longest induced cycle from Circuit: Digraph on 0 vertices
7959+
7960+
Longest (induced) cycle when considering edge weights::
7961+
7962+
sage: D = digraphs.Circuit(15)
7963+
sage: for u, v in D.edges(labels=False):
7964+
....: D.set_edge_label(u, v, 1)
7965+
sage: D.add_edge(0, 10, 50)
7966+
sage: D.add_edge(11, 1, 1)
7967+
sage: D.add_edge(13, 0, 1)
7968+
sage: D.longest_cycle(induced=False, use_edge_labels=False)
7969+
longest cycle from Circuit: Digraph on 15 vertices
7970+
sage: D.longest_cycle(induced=False, use_edge_labels=True)
7971+
(55, longest cycle from Circuit: Digraph on 6 vertices)
7972+
sage: D.longest_cycle(induced=True, use_edge_labels=False)
7973+
longest induced cycle from Circuit: Digraph on 11 vertices
7974+
sage: D.longest_cycle(induced=True, use_edge_labels=True)
7975+
(54, longest induced cycle from Circuit: Digraph on 5 vertices)
7976+
7977+
TESTS:
7978+
7979+
Small cases::
7980+
7981+
sage: Graph().longest_cycle()
7982+
longest cycle: Graph on 0 vertices
7983+
sage: Graph(1).longest_cycle()
7984+
longest cycle: Graph on 0 vertices
7985+
sage: Graph([(0, 1)]).longest_cycle()
7986+
longest cycle: Graph on 0 vertices
7987+
sage: Graph([(0, 1), (1, 2)]).longest_cycle()
7988+
longest cycle: Graph on 0 vertices
7989+
sage: Graph([(0, 1), (1, 2), (0, 2)]).longest_cycle()
7990+
longest cycle: Graph on 3 vertices
7991+
sage: Graph([(0, 1), (1, 2), (0, 2)]).longest_cycle(induced=True)
7992+
longest induced cycle: Graph on 0 vertices
7993+
sage: DiGraph().longest_cycle()
7994+
longest cycle: Digraph on 0 vertices
7995+
sage: DiGraph(1).longest_cycle()
7996+
longest cycle: Digraph on 0 vertices
7997+
sage: DiGraph([(0, 1), (1, 0)]).longest_cycle()
7998+
longest cycle: Digraph on 2 vertices
7999+
sage: DiGraph([(0, 1), (1, 0)]).longest_cycle(induced=True)
8000+
longest induced cycle: Digraph on 0 vertices
8001+
8002+
Disconnected digraph::
8003+
8004+
sage: D = digraphs.Circuit(5) + digraphs.Circuit(4)
8005+
sage: D.longest_cycle()
8006+
longest cycle from Subgraph of (Circuit disjoint_union Circuit): Digraph on 5 vertices
8007+
sage: D.longest_cycle(induced=True)
8008+
longest induced cycle from Subgraph of (Circuit disjoint_union Circuit): Digraph on 5 vertices
8009+
"""
8010+
self._scream_if_not_simple()
8011+
G = self
8012+
st = f" from {G.name()}" if G.name() else ""
8013+
name = f"longest{' induced' if induced else ''} cycle{st}"
8014+
8015+
# Helper functions to manipulate weights
8016+
if use_edge_labels:
8017+
def weight(e):
8018+
return 1 if (len(e) < 3 or e[2] is None) else e[2]
8019+
8020+
def total_weight(gg):
8021+
return sum(weight(e) for e in gg.edge_iterator())
8022+
else:
8023+
def weight(e):
8024+
return 1
8025+
8026+
def total_weight(gg):
8027+
return gg.order()
8028+
8029+
directed = G.is_directed()
8030+
immutable = G.is_immutable()
8031+
if directed:
8032+
from sage.graphs.digraph import DiGraph as MyGraph
8033+
blocks = G.strongly_connected_components()
8034+
else:
8035+
from sage.graphs.graph import Graph as MyGraph
8036+
blocks = G.blocks_and_cut_vertices()[0]
8037+
8038+
# Deal with graphs with multiple biconnected components
8039+
if len(blocks) > 1:
8040+
best = MyGraph(name=name, immutable=immutable)
8041+
best_w = 0
8042+
for block in blocks:
8043+
if induced and len(block) < 4:
8044+
continue
8045+
h = G.subgraph(vertices=block)
8046+
C = h.longest_cycle(induced=induced,
8047+
use_edge_labels=use_edge_labels,
8048+
solver=solver, verbose=verbose,
8049+
integrality_tolerance=integrality_tolerance)
8050+
if total_weight(C) > best_w:
8051+
best = C
8052+
best_w = total_weight(C)
8053+
return (best_w, best) if use_edge_labels else best
8054+
8055+
# We now know that the graph is biconnected or that the digraph is
8056+
# strongly connected.
8057+
8058+
if ((induced and G.order() < 4) or
8059+
(not induced and ((directed and G.order() < 2) or
8060+
(not directed and G.order() < 3)))):
8061+
if use_edge_labels:
8062+
return 0, MyGraph(name=name, immutable=immutable)
8063+
return MyGraph(name=name, immutable=immutable)
8064+
if (not induced and ((directed and G.order() == 2) or
8065+
(not directed and G.order() == 3))):
8066+
answer = G.copy()
8067+
answer.name(name)
8068+
if use_edge_labels:
8069+
return total_weight(answer), answer
8070+
return answer
8071+
8072+
# Helper functions to index edges
8073+
if directed:
8074+
def F(e):
8075+
return e[:2]
8076+
else:
8077+
def F(e):
8078+
return frozenset(e[:2])
8079+
8080+
from sage.numerical.mip import MixedIntegerLinearProgram
8081+
from sage.numerical.mip import MIPSolverException
8082+
8083+
p = MixedIntegerLinearProgram(maximization=True,
8084+
solver=solver,
8085+
constraint_generation=True)
8086+
8087+
# We need one binary variable per vertex and per edge
8088+
vertex = p.new_variable(binary=True)
8089+
edge = p.new_variable(binary=True)
8090+
8091+
# Objective function: maximize the size of the cycle
8092+
p.set_objective(p.sum(weight(e) * edge[F(e)] for e in G.edge_iterator()))
8093+
8094+
# We select as many vertices as edges
8095+
p.add_constraint(p.sum(edge[F(e)] for e in G.edge_iterator())
8096+
== p.sum(vertex[u] for u in G))
8097+
8098+
if directed:
8099+
# If a vertex is selected, one of its incoming (resp. outgoing) edge
8100+
# must be selected, and none of them otherwise
8101+
for u in G:
8102+
p.add_constraint(p.sum(edge[F(e)] for e in G.outgoing_edge_iterator(u))
8103+
<= vertex[u])
8104+
p.add_constraint(p.sum(edge[F(e)] for e in G.incoming_edge_iterator(u))
8105+
<= vertex[u])
8106+
else:
8107+
# If a vertex is selected, two of its incident edges must be
8108+
# selected, and none of them otherwise
8109+
for u in G:
8110+
p.add_constraint(p.sum(edge[F(e)] for e in G.edge_iterator(u))
8111+
<= 2 * vertex[u])
8112+
8113+
if induced:
8114+
# An edge is selected if its end vertices are.
8115+
# We use the linearization of the quadratic constraint
8116+
# vertex[u] * vertex[v] == edge[F((u, v))]
8117+
for e in G.edge_iterator():
8118+
f = F(e)
8119+
u, v = f
8120+
p.add_constraint(edge[f] <= vertex[u])
8121+
p.add_constraint(edge[f] <= vertex[v])
8122+
p.add_constraint(vertex[u] + vertex[v] <= edge[f] + 1)
8123+
8124+
# An induced cycle has at least 4 vertices
8125+
p.add_constraint(p.sum(vertex[u] for u in G), min=4)
8126+
8127+
best = MyGraph(name=name, immutable=immutable)
8128+
best_w = 0
8129+
8130+
# We add cut constraints for as long as we find solutions
8131+
while True:
8132+
try:
8133+
p.solve(log=verbose)
8134+
except MIPSolverException:
8135+
# No (new) solution found
8136+
break
8137+
8138+
# We build the Graph representing the current solution
8139+
b_val = p.get_values(edge, convert=bool, tolerance=integrality_tolerance)
8140+
edges = (e for e in G.edge_iterator() if b_val[F(e)])
8141+
h = MyGraph(edges, format='list_of_edges', name=name, immutable=immutable)
8142+
if not h:
8143+
# No new solution found
8144+
break
8145+
8146+
# If there is only one cycle, we are done !
8147+
if directed:
8148+
cc = h.strongly_connected_components()
8149+
else:
8150+
cc = h.connected_components(sort=False)
8151+
if len(cc) == 1:
8152+
if total_weight(h) > best_w:
8153+
best = h
8154+
best_w = total_weight(best)
8155+
break
8156+
8157+
# Otherwise, we add subtour elimination constraints
8158+
for c in cc:
8159+
if not (induced and len(c) < 4):
8160+
hh = h.subgraph(vertices=c)
8161+
if total_weight(hh) > best_w:
8162+
best = hh
8163+
best.name(name)
8164+
best_w = total_weight(best)
8165+
8166+
# Add subtour elimination constraints
8167+
if directed:
8168+
p.add_constraint(p.sum(edge[F(e)] for e in G.edge_boundary(c)), min=1)
8169+
c = set(c)
8170+
cbar = (v for v in G if v not in c)
8171+
p.add_constraint(p.sum(edge[F(e)] for e in G.edge_boundary(cbar, c)), min=1)
8172+
else:
8173+
p.add_constraint(p.sum(edge[F(e)] for e in G.edge_boundary(c)), min=2)
8174+
8175+
if induced:
8176+
# We eliminate this cycle
8177+
p.add_constraint(p.sum(vertex[u] for u in c) <= len(c) - 1)
8178+
8179+
# We finally set the positions of the vertices and return the result
8180+
if G.get_pos():
8181+
best.set_pos({u: pp for u, pp in G.get_pos().items() if u in best})
8182+
return (best_w, best) if use_edge_labels else best
8183+
78778184
def longest_path(self, s=None, t=None, use_edge_labels=False, algorithm="MILP",
78788185
solver=None, verbose=0, *, integrality_tolerance=1e-3):
78798186
r"""

0 commit comments

Comments
 (0)