Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- `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 @@ -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",
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

*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 @@ -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")
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
1 change: 1 addition & 0 deletions test/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
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