Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ We follow SemVer as most of the Julia ecosystem. Below you might see the "breaki
- `count_connected_components` for efficiently counting connected components without materializing them
- `connected_components!` is now exported and accepts an optional `search_queue` argument to reduce allocations
- `is_connected` optimized to avoid allocating component vectors
- `is_chordal` function

## v1.13.0 - 2025-06-05
- **(breaking)** Julia v1.10 (LTS) minimum version requirement
Expand Down
1 change: 1 addition & 0 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pages_files = [
"Algorithms API" => [
"algorithms/biconnectivity.md",
"algorithms/centrality.md",
"algorithms/chordality.md",
"algorithms/community.md",
"algorithms/connectivity.md",
"algorithms/cut.md",
Expand Down
17 changes: 17 additions & 0 deletions docs/src/algorithms/chordality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Degeneracy
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the title be Chordality instead of Degeneracy?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I'm fairly certain I copy-pasted that from another docs file to get the same structure! I'll fix it once I'm back at my computer.


*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"]

```
4 changes: 4 additions & 0 deletions src/Graphs.jl
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ export
# coloring
greedy_color,

# chordality
is_chordal,

# connectivity
connected_components,
connected_components!,
Expand Down Expand Up @@ -525,6 +528,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")
Expand Down
103 changes: 103 additions & 0 deletions src/chordality.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
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 vertices 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
```jldoctest
julia> using Graphs

julia> is_chordal(cycle_graph(3))
true

julia> is_chordal(cycle_graph(4))
false

julia> g = cycle_graph(4); add_edge!(g, 1, 3);

julia> is_chordal(g)
true

```
"""
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
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
if nv(g) < 4
return true
end

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`, `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_vertex(g, unnumbered, numbered)
delete!(unnumbered, v)
push!(numbered, v)
subsequent_neighbors = filter(in(numbered), collect(neighbors(g, v)))

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
every `subsequent_neighbors` set induced a complete subgraph, `g` must be chordal. =#
return true
end

function _max_cardinality_vertex(
g::AbstractGraph{T}, unnumbered::Set{T}, numbered::Set{T}
) where {T}
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)
if !has_edge(g, u, v)
return false
end
end

return true
end
120 changes: 120 additions & 0 deletions test/chordality.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
@testset "Chordality" begin
rng = StableRNG(737362)

# Chordal: 4-cycle with a chord
c4_chorded = cycle_graph(4)
add_edge!(c4_chorded, 1, 3)

# Chordal: Figure 2 from Tarjan and Yannakakis (1984) (cited in `src/chordality.jl`)
fig2_ty84 = SimpleGraph(10)
for (u, v) in [
(1, 2),
(1, 5),
(2, 4),
(2, 5),
(2, 6),
(3, 4),
(3, 5),
(3, 7),
(4, 5),
(4, 6),
(4, 7),
(5, 6),
(7, 8),
(7, 9),
(8, 9),
(8, 10),
(9, 10),
]
add_edge!(fig2_ty84, u, v)
end

# Non-chordal: Figure 1 from Tarjan and Yannakakis (1984) (cited in `src/chordality.jl`)
fig1_ty84 = SimpleGraph(9)
for (u, v) in [
(1, 2),
(1, 3),
(1, 9),
(2, 3),
(2, 4),
(3, 5),
(3, 8),
(4, 5),
(4, 6),
(5, 6),
(5, 8),
(6, 7),
(7, 8),
(8, 9),
]
add_edge!(fig1_ty84, u, v)
end

@testset "chordal" begin
@testset "$(typeof(g))" for g in test_generic_graphs(
SimpleGraph(0), # Empty graph
SimpleGraph(1), # Singleton graph
path_graph(2),
cycle_graph(3),
path_graph(10),
star_graph(6),
complete_graph(5),
blockdiag(cycle_graph(3), cycle_graph(3)), # Disconnected case
c4_chorded,
fig2_ty84,
)
@test @inferred(is_chordal(g))
end
end

@testset "non-chordal" begin
@testset "$(typeof(g))" for g in test_generic_graphs(
cycle_graph(4),
cycle_graph(5),
cycle_graph(6),
cycle_graph(10),
smallgraph(:petersen),
complete_bipartite_graph(2, 3),
grid([2, 3]),
blockdiag(cycle_graph(3), cycle_graph(4)), # Disconnected case
fig1_ty84,
)
@test @inferred(!is_chordal(g))
end
end

#= The probability of a random labelled graph on `n ∈ {5, 6, 7, 8}` vertices being
chordal is, depending on the `n`, between 11.5% and 80.3% (OEIS A058862 vs. A006125).
Therefore, even in the "worst" case, we can be confident that at least a few of the 20
test cases for each `n` are chordal (and since we use a random seed, we can confirm
that this is indeed the case). =#
@testset "random" begin
for n in 5:8, _ in 1:20
#= The Erdős–Rényi distribution with edge probability 0.5 is precisely the
uniform distribution of all labelled graphs on `n` vertices, so this is
equivalent to sampling a random labelled graph. =#
g = erdos_renyi(n, 0.5; rng=rng)
# `LibIGraph.is_chordal` returns a tuple, not a boolean, so we need `first`
expected = first(
LibIGraph.is_chordal(IGraph(g), IGNull(), IGNull(), IGNull(), IGNull())
)

for gg in test_generic_graphs(g)
@test @inferred(is_chordal(gg)) == expected
end
end
end

#= `is_chordal` is not implemented for directed graphs (a `MethodError` is thrown) or
for graphs with self-loops (an `ArgumentError` is thrown). =#
@testset "errors" begin
g_loop = copy(cycle_graph(4))
add_edge!(g_loop, 1, 1)

@testset "$(typeof(g))" for g in test_generic_graphs(g_loop)
@test_throws ArgumentError is_chordal(g)
end

@test_throws MethodError is_chordal(cycle_digraph(4))
end
end
2 changes: 2 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ using Random
using Logging: NullLogger, with_logger
using Statistics: mean, std
using StableRNGs
using IGraphs
using Pkg
using Unitful

Expand Down Expand Up @@ -93,6 +94,7 @@ tests = [
"cycles/limited_length",
"cycles/incremental",
"edit_distance",
"chordality",
"connectivity",
"persistence/persistence",
"shortestpaths/utils",
Expand Down
Loading