Skip to main content

rust_igraph/algorithms/properties/
is_acyclic.rs

1//! Acyclic predicate (ALGO-PR-022).
2//!
3//! Counterpart of `igraph_is_acyclic()` from
4//! `references/igraph/src/properties/trees.c:753`. Returns `true`
5//! iff `graph` contains no cycle (of any length). For directed
6//! graphs this delegates to [`crate::is_dag`]; for undirected
7//! graphs we run union-find over the edges — a cycle is the first
8//! edge whose endpoints are already in the same connected
9//! component. Self-loops and parallel edges count as cycles.
10//!
11//! Time complexity: `O(V + E · α(E))` where `α` is the inverse
12//! Ackermann function (effectively `O(V + E)`).
13
14use crate::algorithms::properties::is_dag::is_dag;
15use crate::core::Graph;
16
17/// Returns `true` iff `graph` contains no cycle.
18///
19/// For directed graphs this is equivalent to [`crate::is_dag`].
20/// For undirected graphs, a cycle is any non-trivial closed walk —
21/// in particular self-loops and parallel undirected edges count
22/// as cycles.
23///
24/// Algorithm:
25/// - Directed: delegate to `is_dag` (matches upstream).
26/// - Undirected: walk every edge; for each `(u, v)` use union-find
27///   to check whether `u` and `v` are already connected. If yes,
28///   we just closed a cycle ⇒ return false. Otherwise union and
29///   continue.
30///
31/// Counterpart of `igraph_is_acyclic` from
32/// `references/igraph/src/properties/trees.c:753`.
33///
34/// # Examples
35///
36/// ```
37/// use rust_igraph::{Graph, is_acyclic};
38///
39/// // Undirected tree 0-1-2-3: acyclic.
40/// let mut g = Graph::with_vertices(4);
41/// g.add_edge(0, 1).unwrap();
42/// g.add_edge(1, 2).unwrap();
43/// g.add_edge(2, 3).unwrap();
44/// assert!(is_acyclic(&g));
45///
46/// // Triangle 0-1-2-0: cyclic.
47/// let mut g = Graph::with_vertices(3);
48/// g.add_edge(0, 1).unwrap();
49/// g.add_edge(1, 2).unwrap();
50/// g.add_edge(2, 0).unwrap();
51/// assert!(!is_acyclic(&g));
52/// ```
53#[must_use]
54pub fn is_acyclic(graph: &Graph) -> bool {
55    if graph.is_directed() {
56        return is_dag(graph);
57    }
58
59    let n = graph.vcount();
60    let m = graph.ecount();
61    let n_us = n as usize;
62
63    // Trivial early outs: no vertices or no edges ⇒ no cycle.
64    if n == 0 || m == 0 {
65        return true;
66    }
67
68    // Union-find with path compression + union-by-rank-ish sizes.
69    let mut parent: Vec<u32> = (0..n).collect();
70    let mut size: Vec<u32> = vec![1; n_us];
71
72    let Ok(m_u32) = u32::try_from(m) else {
73        // Edge count beyond u32::MAX is impossible (EdgeId is u32),
74        // but if it did happen we'd lose precision: treat as cyclic
75        // to be safe.
76        return false;
77    };
78    for eid in 0..m_u32 {
79        // Should not happen on a well-formed Graph; fall back to
80        // "cyclic" (conservative).
81        let Ok((u, v)) = graph.edge(eid) else {
82            return false;
83        };
84        if u == v {
85            // Self-loop: instant cycle.
86            return false;
87        }
88        let mut ru = u as usize;
89        while parent[ru] as usize != ru {
90            parent[ru] = parent[parent[ru] as usize];
91            ru = parent[ru] as usize;
92        }
93        let mut rv = v as usize;
94        while parent[rv] as usize != rv {
95            parent[rv] = parent[parent[rv] as usize];
96            rv = parent[rv] as usize;
97        }
98        if ru == rv {
99            // Already in the same component ⇒ this edge closes a
100            // cycle. Parallel undirected edges land here on the
101            // second occurrence.
102            return false;
103        }
104        // Union by size: attach smaller under larger.
105        let (parent_idx, child_idx) = if size[ru] < size[rv] {
106            (rv, ru)
107        } else {
108            (ru, rv)
109        };
110        let Ok(parent_u32) = u32::try_from(parent_idx) else {
111            return false;
112        };
113        parent[child_idx] = parent_u32;
114        size[parent_idx] = size[parent_idx].saturating_add(size[child_idx]);
115    }
116
117    true
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::core::Graph;
124
125    // -------- Undirected cases --------
126
127    #[test]
128    fn empty_undirected_is_acyclic() {
129        let g = Graph::with_vertices(0);
130        assert!(is_acyclic(&g));
131    }
132
133    #[test]
134    fn isolated_vertices_only_is_acyclic() {
135        let g = Graph::with_vertices(5);
136        assert!(is_acyclic(&g));
137    }
138
139    #[test]
140    fn undirected_tree_is_acyclic() {
141        let mut g = Graph::with_vertices(4);
142        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 3)]).unwrap();
143        assert!(is_acyclic(&g));
144    }
145
146    #[test]
147    fn undirected_forest_is_acyclic() {
148        // Two disjoint trees: 0-1-2 and 3-4.
149        let mut g = Graph::with_vertices(5);
150        g.add_edges(vec![(0u32, 1u32), (1, 2), (3, 4)]).unwrap();
151        assert!(is_acyclic(&g));
152    }
153
154    #[test]
155    fn undirected_triangle_is_not_acyclic() {
156        let mut g = Graph::with_vertices(3);
157        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 0)]).unwrap();
158        assert!(!is_acyclic(&g));
159    }
160
161    #[test]
162    fn undirected_self_loop_is_not_acyclic() {
163        let mut g = Graph::with_vertices(2);
164        g.add_edge(0, 0).unwrap();
165        g.add_edge(0, 1).unwrap();
166        assert!(!is_acyclic(&g));
167    }
168
169    #[test]
170    fn undirected_parallel_edge_is_not_acyclic() {
171        // Two parallel edges between 0 and 1 form a 2-cycle.
172        let mut g = Graph::with_vertices(2);
173        g.add_edge(0, 1).unwrap();
174        g.add_edge(0, 1).unwrap();
175        assert!(!is_acyclic(&g));
176    }
177
178    #[test]
179    fn undirected_star_is_acyclic() {
180        // Star around vertex 0.
181        let mut g = Graph::with_vertices(5);
182        g.add_edges(vec![(0u32, 1u32), (0, 2), (0, 3), (0, 4)])
183            .unwrap();
184        assert!(is_acyclic(&g));
185    }
186
187    // -------- Directed cases (delegated to is_dag) --------
188
189    #[test]
190    fn directed_dag_chain_is_acyclic() {
191        let mut g = Graph::new(3, true).unwrap();
192        g.add_edges(vec![(0u32, 1u32), (1, 2)]).unwrap();
193        assert!(is_acyclic(&g));
194    }
195
196    #[test]
197    fn directed_cycle_is_not_acyclic() {
198        let mut g = Graph::new(3, true).unwrap();
199        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 0)]).unwrap();
200        assert!(!is_acyclic(&g));
201    }
202
203    #[test]
204    fn directed_self_loop_is_not_acyclic() {
205        let mut g = Graph::new(2, true).unwrap();
206        g.add_edge(0, 0).unwrap();
207        g.add_edge(0, 1).unwrap();
208        assert!(!is_acyclic(&g));
209    }
210
211    #[test]
212    fn directed_diamond_dag_is_acyclic() {
213        // 0→1, 0→2, 1→3, 2→3.
214        let mut g = Graph::new(4, true).unwrap();
215        g.add_edges(vec![(0u32, 1u32), (0, 2), (1, 3), (2, 3)])
216            .unwrap();
217        assert!(is_acyclic(&g));
218    }
219
220    #[test]
221    fn undirected_no_edges_with_many_vertices_is_acyclic() {
222        let g = Graph::with_vertices(100);
223        assert!(is_acyclic(&g));
224    }
225}