From 32cc562674cce8ef3b307dbf439cadc10cb6ef8d Mon Sep 17 00:00:00 2001 From: Luis-Varona Date: Mon, 2 Jun 2025 16:58:09 -0300 Subject: [PATCH 1/3] WIP: Add an is_chordal algorithm We implement Tarjan and Yannakakis (1984)'s MCS algorithm, taking inspiration from the existing NetworkX implementation. Everything is done except for example doctests in src/chordality.jl and unit tests in test/chordality.jl. (This PR is part of a new suite of algorithms outlined in issue #431.) --- src/Graphs.jl | 4 +++ src/chordality.jl | 78 ++++++++++++++++++++++++++++++++++++++++++++++ test/chordality.jl | 3 ++ test/runtests.jl | 1 + 4 files changed, 86 insertions(+) create mode 100644 src/chordality.jl create mode 100644 test/chordality.jl diff --git a/src/Graphs.jl b/src/Graphs.jl index ed96e6571..47066210b 100644 --- a/src/Graphs.jl +++ b/src/Graphs.jl @@ -205,6 +205,9 @@ export # coloring greedy_color, + # chordality + is_chordal, + # connectivity connected_components, strongly_connected_components, @@ -504,6 +507,7 @@ include("iterators/bfs.jl") include("iterators/dfs.jl") include("traversals/eulerian.jl") include("traversals/all_simple_paths.jl") +include("chordality.jl") include("connectivity.jl") include("distance.jl") include("editdist.jl") diff --git a/src/chordality.jl b/src/chordality.jl new file mode 100644 index 000000000..2f8c5c39f --- /dev/null +++ b/src/chordality.jl @@ -0,0 +1,78 @@ +""" + is_chordal(g) + +Check whether a graph is chordal. + +A graph is said to be *chordal* if every cycle of length `≥ 4` has a chord +(i.e., an edge between two nodes not adjacent in the cycle). + +### Performance +This algorithm is linear in the number of vertices and edges of the graph (i.e., +it runs in `O(nv(g) + ne(g))` time). + +### Implementation Notes +`g` is chordal if and only if it admits a perfect elimination ordering—that is, +an ordering of the vertices of `g` such that for every vertex `v`, the set of +all neighbors of `v` that come later in the ordering forms a complete graph. +This is precisely the condition checked by the maximum cardinality search +algorithm [1], implemented herein. + +We take heavy inspiration here from the existing Python implementation in [2]. + +Not implemented for directed graphs, graphs with self-loops, or graphs with +parallel edges. + +### References +[1] Tarjan, Robert E. and Mihalis Yannakakis. "Simple Linear-Time Algorithms to + Test Chordality of Graphs, Test Acyclicity of Hypergraphs, and Selectively + Reduce Acyclic Hypergraphs." *SIAM Journal on Computing* 13, no. 3 (1984): + 566–79. https://doi.org/10.1137/0213035. +[2] NetworkX Developers. "is_chordal." NetworkX 3.5 documentation. NetworkX, + May 29, 2025. Accessed June 2, 2025. + https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.chordal.is_chordal.html. + +# Examples +TODO: Add examples +""" +function is_chordal(g::AbstractSimpleGraph) + # The possibility of self-loops is already ruled out by the `AbstractSimpleGraph` type + is_directed(g) && throw(ArgumentError("Graph must be undirected")) + has_self_loops(g) && throw(ArgumentError("Graph must not have self-loops")) + + # Every graph of order `< 4` has no cycles of length `≥ 4` and thus is trivially chordal + nv(g) < 4 && return true + + unnumbered = Set(vertices(g)) + start_vertex = pop!(unnumbered) # The search can start from any arbitrary vertex + numbered = Set(start_vertex) + + #= Searching by maximum cardinality ensures that in any possible perfect elimination + ordering of `g`, `purported_clique_nodes` is precisely the set of neighbors of `v` that + come later in the ordering. Hence, if the subgraph induced by `purported_clique_nodes` + in any iteration is not complete, `g` cannot be chordal. =# + while !isempty(unnumbered) + # `v` is the vertex in `unnumbered` with the most neighbors in `numbered` + v = _max_cardinality_node(g, unnumbered, numbered) + delete!(unnumbered, v) + push!(numbered, v) + + # A complete subgraph of a larger graph is called a "clique," hence the naming here + purported_clique_nodes = intersect(neighbors(g, v), numbered) + purported_clique = induced_subgraph(g, purported_clique_nodes) + + _is_complete_graph(purported_clique) || return false + end + + #= That `g` admits a perfect elimination ordering is an "if and only if" condition for + chordality, so if every `purported_clique` was indeed complete, `g` must be chordal. =# + return true +end + +function _max_cardinality_node( + g::AbstractSimpleGraph, unnumbered::Set{T}, numbered::Set{T} +) where {T} + cardinality(v::T) = count(in(numbered), neighbors(g, v)) + return argmax(cardinality, unnumbered) +end + +_is_complete_graph(g::AbstractSimpleGraph) = density(g) == 1 diff --git a/test/chordality.jl b/test/chordality.jl new file mode 100644 index 000000000..89c6a299c --- /dev/null +++ b/test/chordality.jl @@ -0,0 +1,3 @@ +@testset "Chordality" begin + # TODO: Add tests +end diff --git a/test/runtests.jl b/test/runtests.jl index 34bd1c53f..568cc0298 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -90,6 +90,7 @@ tests = [ "cycles/limited_length", "cycles/incremental", "edit_distance", + "chordality", "connectivity", "persistence/persistence", "shortestpaths/utils", From 6c2fa92e0572c61506555bb0622810f231bafaa3 Mon Sep 17 00:00:00 2001 From: Luis-Varona Date: Sun, 8 Jun 2025 13:48:55 -0400 Subject: [PATCH 2/3] Fix input type for is_chordal Originally, we restricted the input type for is_chordal to AbstractSimpleGraph to rule out parallel edges, but some other graph types we would like to support (such as SimpleWeightedGraph) are of the more general AbstractGraph type. It turns out the current AbstractGraph interface aleady does not support parallel edges, so there is no worry here--it is already stated in the Implementation Notes anyway that is_chordal should not take in graphs with parallel edges as input. --- src/chordality.jl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/chordality.jl b/src/chordality.jl index 2f8c5c39f..4215f90d2 100644 --- a/src/chordality.jl +++ b/src/chordality.jl @@ -34,8 +34,8 @@ parallel edges. # Examples TODO: Add examples """ -function is_chordal(g::AbstractSimpleGraph) - # The possibility of self-loops is already ruled out by the `AbstractSimpleGraph` type +function is_chordal(g::AbstractGraph) + # The `AbstractGraph` interface does not support parallel edges, so no need to check is_directed(g) && throw(ArgumentError("Graph must be undirected")) has_self_loops(g) && throw(ArgumentError("Graph must not have self-loops")) @@ -69,10 +69,10 @@ function is_chordal(g::AbstractSimpleGraph) end function _max_cardinality_node( - g::AbstractSimpleGraph, unnumbered::Set{T}, numbered::Set{T} + g::AbstractGraph, unnumbered::Set{T}, numbered::Set{T} ) where {T} cardinality(v::T) = count(in(numbered), neighbors(g, v)) return argmax(cardinality, unnumbered) end -_is_complete_graph(g::AbstractSimpleGraph) = density(g) == 1 +_is_complete_graph(g::AbstractGraph) = density(g) == 1 From eeb10f4d7857f642aa04eda03b59d7d2961ec681 Mon Sep 17 00:00:00 2001 From: Luis-Varona Date: Sun, 15 Jun 2025 22:49:21 -0300 Subject: [PATCH 3/3] Stop allocating induced subgraphs Originally mirroring the networkx implementation, we created a new AbstractGraph object every time we tested subsequent neighbors in the potential PEO with the induced_subgraph function. This commit makes our implementation more performant by simply taking the vertex (sub)set and checking to see if all pairs are adjacent via iteration. Additionally, we change inconsistent naming in certain parts ('node' vs 'vertex'), changing everything to 'vertex' where relevant. --- src/chordality.jl | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/chordality.jl b/src/chordality.jl index 4215f90d2..e20e31c91 100644 --- a/src/chordality.jl +++ b/src/chordality.jl @@ -4,7 +4,7 @@ Check whether a graph is chordal. A graph is said to be *chordal* if every cycle of length `≥ 4` has a chord -(i.e., an edge between two nodes not adjacent in the cycle). +(i.e., an edge between two vertices not adjacent in the cycle). ### Performance This algorithm is linear in the number of vertices and edges of the graph (i.e., @@ -47,32 +47,36 @@ function is_chordal(g::AbstractGraph) numbered = Set(start_vertex) #= Searching by maximum cardinality ensures that in any possible perfect elimination - ordering of `g`, `purported_clique_nodes` is precisely the set of neighbors of `v` that - come later in the ordering. Hence, if the subgraph induced by `purported_clique_nodes` + ordering of `g`, `subsequent_neighbors` is precisely the set of neighbors of `v` that + come later in the ordering. Therefore, if the subgraph induced by `subsequent_neighbors` in any iteration is not complete, `g` cannot be chordal. =# while !isempty(unnumbered) # `v` is the vertex in `unnumbered` with the most neighbors in `numbered` - v = _max_cardinality_node(g, unnumbered, numbered) + v = _max_cardinality_vertex(g, unnumbered, numbered) delete!(unnumbered, v) push!(numbered, v) + subsequent_neighbors = intersect(neighbors(g, v), numbered) - # A complete subgraph of a larger graph is called a "clique," hence the naming here - purported_clique_nodes = intersect(neighbors(g, v), numbered) - purported_clique = induced_subgraph(g, purported_clique_nodes) - - _is_complete_graph(purported_clique) || return false + # A complete subgraph is also called a "clique," hence the naming here + _induces_clique(subsequent_neighbors, g) || return false end - #= That `g` admits a perfect elimination ordering is an "if and only if" condition for - chordality, so if every `purported_clique` was indeed complete, `g` must be chordal. =# + #= A perfect elimination ordering is an "if and only if" condition for chordality, so if + every `subsequent_neighbors` set induced a complete subgraph, `g` must be chordal. =# return true end -function _max_cardinality_node( - g::AbstractGraph, unnumbered::Set{T}, numbered::Set{T} +function _max_cardinality_vertex( + g::AbstractGraph{T}, unnumbered::Set{T}, numbered::Set{T} ) where {T} cardinality(v::T) = count(in(numbered), neighbors(g, v)) return argmax(cardinality, unnumbered) end -_is_complete_graph(g::AbstractGraph) = density(g) == 1 +function _induces_clique(vertex_subset::Vector{T}, g::AbstractGraph{T}) where {T} + for (i, u) in enumerate(vertex_subset), v in Iterators.drop(vertex_subset, i) + has_edge(g, u, v) || return false + end + + return true +end