From 8711288b2e239a716411c278136b6ff65dfdbe54 Mon Sep 17 00:00:00 2001 From: Luis-Varona Date: Mon, 2 Jun 2025 16:58:09 -0300 Subject: [PATCH 1/9] 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 f9ae18680..db0bbd473 100644 --- a/src/Graphs.jl +++ b/src/Graphs.jl @@ -210,6 +210,9 @@ export # coloring greedy_color, + # chordality + is_chordal, + # connectivity connected_components, connected_components!, @@ -520,6 +523,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 4045eb60b..df114a8e3 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -93,6 +93,7 @@ tests = [ "cycles/limited_length", "cycles/incremental", "edit_distance", + "chordality", "connectivity", "persistence/persistence", "shortestpaths/utils", From f3d4d490793062cae8f74e8aa5604a1bd2425d74 Mon Sep 17 00:00:00 2001 From: Luis-Varona Date: Sun, 8 Jun 2025 13:48:55 -0400 Subject: [PATCH 2/9] 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 2dd52c96f31c7068d6976a75ab1b4ff785834b3f Mon Sep 17 00:00:00 2001 From: Luis-Varona Date: Sun, 15 Jun 2025 22:49:21 -0300 Subject: [PATCH 3/9] 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 From 4e46aabe9f683bc9f8996de6ed0e263d71777c17 Mon Sep 17 00:00:00 2001 From: "Luis M. B. Varona" Date: Tue, 24 Feb 2026 12:41:30 -0400 Subject: [PATCH 4/9] Edit 'is_chordal': use trait dispatch, add docstring examples --- src/chordality.jl | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/chordality.jl b/src/chordality.jl index e20e31c91..258590f13 100644 --- a/src/chordality.jl +++ b/src/chordality.jl @@ -31,12 +31,27 @@ parallel edges. 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 +### Examples +```jldoctest +julia> using Graphs + +julia> is_chordal(cycle_graph(3)) +true + +julia> is_chordal(cycle_graph(4)) +false + +julia> g = SimpleGraph(4); add_edge!(g, 1, 2); add_edge!(g, 2, 3); add_edge!(g, 3, 4); add_edge!(g, 4, 1); add_edge!(g, 1, 3); + +julia> is_chordal(g) +true + +``` """ -function is_chordal(g::AbstractGraph) +function is_chordal end + +@traitfn function is_chordal(g::AG::(!IsDirected)) where {AG<: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")) # Every graph of order `< 4` has no cycles of length `≥ 4` and thus is trivially chordal From fdd16333ae700cb1916c492abb785a959096e49d Mon Sep 17 00:00:00 2001 From: "Luis M. B. Varona" Date: Tue, 24 Feb 2026 13:02:43 -0400 Subject: [PATCH 5/9] Add chordality docs page We create docs/src/algorithms/chordality.md, adding it to the Algorithms API section of docs/make.jl. --- docs/make.jl | 1 + docs/src/algorithms/chordality.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 docs/src/algorithms/chordality.md diff --git a/docs/make.jl b/docs/make.jl index 0eb9a74a0..522ad31ff 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -54,6 +54,7 @@ pages_files = [ "Algorithms API" => [ "algorithms/biconnectivity.md", "algorithms/centrality.md", + "algorithms/chordality.md", "algorithms/community.md", "algorithms/connectivity.md", "algorithms/cut.md", diff --git a/docs/src/algorithms/chordality.md b/docs/src/algorithms/chordality.md new file mode 100644 index 000000000..dc3d3ef8b --- /dev/null +++ b/docs/src/algorithms/chordality.md @@ -0,0 +1,17 @@ +# Degeneracy + +*Graphs.jl* provides functionality for checking whether a graph is [chordal](https://en.wikipedia.org/wiki/Chordal_graph). + +## Index + +```@index +Pages = ["chordality.md"] +``` + +## Full docs + +```@autodocs +Modules = [Graphs] +Pages = ["chordality.jl"] + +``` From 0cc471e0f84c78819dd91e33008183d94a748711 Mon Sep 17 00:00:00 2001 From: "Luis M. B. Varona" Date: Fri, 27 Feb 2026 13:30:47 -0400 Subject: [PATCH 6/9] Add IGraphs as test dependency We add IGraphs as a test dependency (to the nested Project.toml in the test folder, and import it as well in runtests) for our soon-to-be-completed chordality test suite. --- test/Project.toml | 1 + test/runtests.jl | 1 + 2 files changed, 2 insertions(+) diff --git a/test/Project.toml b/test/Project.toml index 5ae3fa603..ec93d1bdc 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -6,6 +6,7 @@ DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" DelimitedFiles = "8bb1440f-4735-579b-a4ab-409b98df4dab" Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +IGraphs = "647e90d3-2106-487c-adb4-c91fc07b96ea" Inflate = "d25df0c9-e2be-5dd7-82c8-3ad0b3e990b9" JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" diff --git a/test/runtests.jl b/test/runtests.jl index df114a8e3..e8d9c130f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -16,6 +16,7 @@ using Random using Logging: NullLogger, with_logger using Statistics: mean, std using StableRNGs +using IGraphs using Pkg using Unitful From 7acd67c4435a0609bdc243507d276fe4dcd31432 Mon Sep 17 00:00:00 2001 From: "Luis M. B. Varona" Date: Sat, 28 Feb 2026 00:26:11 -0400 Subject: [PATCH 7/9] Fix is_chordal for GenericGraph vertex types The GenericGraph type used in the unit tests returns a generator object when neighbors is called on it. Originally, we computed the subsequent_neighbors variable via intersect(neighbors(g, v), numbered), but intersect could not infer the element type of a generator object and fell back to Vector{Any}. This caused a MethodError when passing the result to _induces_clique, so we replaced the intersect call with a filter call. --- src/chordality.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chordality.jl b/src/chordality.jl index 258590f13..215ee4dea 100644 --- a/src/chordality.jl +++ b/src/chordality.jl @@ -70,7 +70,7 @@ function is_chordal end v = _max_cardinality_vertex(g, unnumbered, numbered) delete!(unnumbered, v) push!(numbered, v) - subsequent_neighbors = intersect(neighbors(g, v), numbered) + subsequent_neighbors = filter(in(numbered), collect(neighbors(g, v))) # A complete subgraph is also called a "clique," hence the naming here _induces_clique(subsequent_neighbors, g) || return false From 10c885b6f1a74d305a9b548a2e4509429a6b0f72 Mon Sep 17 00:00:00 2001 From: "Luis M. B. Varona" Date: Sat, 28 Feb 2026 00:41:14 -0400 Subject: [PATCH 8/9] Refactor 'is_chordal' (and helpers) for clarity Short-circuit one-liners (e.g., 'A || do B') are expanded into 'if' blocks (similarly, 'if !A; do B; end'), and the cardinality closure in '_max_cardinality_vertex' is inlined as a lambda function. --- src/chordality.jl | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/chordality.jl b/src/chordality.jl index 215ee4dea..a65456b67 100644 --- a/src/chordality.jl +++ b/src/chordality.jl @@ -52,10 +52,14 @@ function is_chordal end @traitfn function is_chordal(g::AG::(!IsDirected)) where {AG<:AbstractGraph} # The `AbstractGraph` interface does not support parallel edges, so no need to check - has_self_loops(g) && throw(ArgumentError("Graph must not have self-loops")) + if has_self_loops(g) + throw(ArgumentError("Graph must not have self-loops")) + end # Every graph of order `< 4` has no cycles of length `≥ 4` and thus is trivially chordal - nv(g) < 4 && return true + if nv(g) < 4 + return true + end unnumbered = Set(vertices(g)) start_vertex = pop!(unnumbered) # The search can start from any arbitrary vertex @@ -72,8 +76,9 @@ function is_chordal end push!(numbered, v) subsequent_neighbors = filter(in(numbered), collect(neighbors(g, v))) - # A complete subgraph is also called a "clique," hence the naming here - _induces_clique(subsequent_neighbors, g) || return false + if !_induces_clique(subsequent_neighbors, g) + return false + end end #= A perfect elimination ordering is an "if and only if" condition for chordality, so if @@ -84,13 +89,14 @@ end 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) + return argmax(v -> count(in(numbered), neighbors(g, v)), unnumbered) end 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 + if !has_edge(g, u, v) + return false + end end return true From 0a9200b82a88c65106d64d92d7367afd8066cc0c Mon Sep 17 00:00:00 2001 From: "Luis M. B. Varona" Date: Tue, 3 Mar 2026 00:08:27 -0400 Subject: [PATCH 9/9] Update is_chordal docstring Update the example in the is_chordal docstring to construct a chorded 4-cycle more idiomatically. --- src/chordality.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chordality.jl b/src/chordality.jl index a65456b67..c3a11d6ff 100644 --- a/src/chordality.jl +++ b/src/chordality.jl @@ -41,7 +41,7 @@ true julia> is_chordal(cycle_graph(4)) false -julia> g = SimpleGraph(4); add_edge!(g, 1, 2); add_edge!(g, 2, 3); add_edge!(g, 3, 4); add_edge!(g, 4, 1); add_edge!(g, 1, 3); +julia> g = cycle_graph(4); add_edge!(g, 1, 3); julia> is_chordal(g) true