Skip to main content

rust_igraph/algorithms/
cycles.rs

1//! Cycle detection (ALGO-CY-001).
2//!
3//! Finds a single cycle in a graph using iterative DFS.
4//! Counterpart of `igraph_find_cycle`.
5
6use crate::core::{Graph, IgraphResult, VertexId};
7
8/// Result of a cycle search.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct CycleResult {
11    /// Vertex IDs forming the cycle (in order). Empty if no cycle exists.
12    pub vertices: Vec<VertexId>,
13    /// Edge IDs forming the cycle (in order). Empty if no cycle exists.
14    pub edges: Vec<u32>,
15}
16
17/// Mode for following edges in directed graphs.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CycleMode {
20    /// Follow outgoing edges.
21    Out,
22    /// Follow incoming edges.
23    In,
24    /// Ignore edge directions.
25    All,
26}
27
28/// Finds a single cycle in the graph, if one exists.
29///
30/// Uses iterative DFS to find a back-edge, then extracts the cycle
31/// from the DFS path. Returns empty vectors if the graph is acyclic.
32///
33/// For undirected graphs, `mode` is ignored and treated as `All`.
34///
35/// # Examples
36///
37/// ```
38/// use rust_igraph::{Graph, find_cycle, CycleMode};
39///
40/// // Directed cycle: 0→1→2→0
41/// let mut g = Graph::new(3, true).unwrap();
42/// g.add_edge(0, 1).unwrap();
43/// g.add_edge(1, 2).unwrap();
44/// g.add_edge(2, 0).unwrap();
45///
46/// let result = find_cycle(&g, CycleMode::Out).unwrap();
47/// // Cycle 0→1→2→0: vertices=[0,1,2,0], edges=[e0,e1,e2]
48/// assert_eq!(result.vertices.len(), 4);
49/// assert_eq!(result.edges.len(), 3);
50/// assert_eq!(result.vertices.first(), result.vertices.last());
51///
52/// // DAG: no cycle
53/// let mut g = Graph::new(3, true).unwrap();
54/// g.add_edge(0, 1).unwrap();
55/// g.add_edge(0, 2).unwrap();
56///
57/// let result = find_cycle(&g, CycleMode::Out).unwrap();
58/// assert!(result.vertices.is_empty());
59/// ```
60pub fn find_cycle(graph: &Graph, mode: CycleMode) -> IgraphResult<CycleResult> {
61    let n = graph.vcount();
62    let ecount = graph.ecount();
63
64    if ecount == 0 {
65        return Ok(CycleResult {
66            vertices: Vec::new(),
67            edges: Vec::new(),
68        });
69    }
70
71    let effective_mode = if graph.is_directed() {
72        mode
73    } else {
74        CycleMode::All
75    };
76
77    let inc = build_incidence(graph, effective_mode)?;
78
79    // DFS state
80    // seen: 0 = unseen, 1 = ancestor (on current path), 2 = fully explored
81    let mut seen: Vec<u8> = vec![0; n as usize];
82    let mut vpath: Vec<VertexId> = Vec::new();
83    let mut epath: Vec<u32> = Vec::new();
84
85    // Stack stores frames: either a vertex+edge to explore or a sentinel (-1)
86    // to pop the path on backtrack.
87    // We use i64 to encode: negative = sentinel, non-negative = vertex/edge pair
88    let mut stack: Vec<StackEntry> = Vec::new();
89
90    for start in 0..n {
91        if seen[start as usize] != 0 {
92            continue;
93        }
94
95        stack.push(StackEntry::Visit {
96            vertex: start,
97            edge: u32::MAX,
98        });
99
100        while let Some(entry) = stack.pop() {
101            match entry {
102                StackEntry::Backtrack => {
103                    if let Some(v) = vpath.pop() {
104                        seen[v as usize] = 2;
105                    }
106                    epath.pop();
107                }
108                StackEntry::Visit {
109                    vertex: va,
110                    edge: ea,
111                } => {
112                    if seen[va as usize] == 1 {
113                        // Found a cycle — va is an ancestor on the current path
114                        let cycle = extract_cycle(&vpath, &epath, va, ea);
115                        return Ok(cycle);
116                    }
117                    if seen[va as usize] == 2 {
118                        continue;
119                    }
120
121                    // Push va onto the path
122                    seen[va as usize] = 1;
123                    vpath.push(va);
124                    epath.push(ea);
125
126                    // Push backtrack sentinel
127                    stack.push(StackEntry::Backtrack);
128
129                    // Push incident edges
130                    for &(nb, eid) in &inc[va as usize] {
131                        if eid == ea {
132                            continue;
133                        }
134                        if seen[nb as usize] == 2 {
135                            continue;
136                        }
137                        stack.push(StackEntry::Visit {
138                            vertex: nb,
139                            edge: eid,
140                        });
141                    }
142                }
143            }
144        }
145    }
146
147    Ok(CycleResult {
148        vertices: Vec::new(),
149        edges: Vec::new(),
150    })
151}
152
153#[derive(Debug)]
154enum StackEntry {
155    Backtrack,
156    Visit { vertex: VertexId, edge: u32 },
157}
158
159fn extract_cycle(
160    vpath: &[VertexId],
161    epath: &[u32],
162    target: VertexId,
163    closing_edge: u32,
164) -> CycleResult {
165    // Find where `target` appears in the path
166    let start_idx = vpath.iter().position(|&v| v == target).unwrap_or(0);
167
168    let mut vertices: Vec<VertexId> = vpath[start_idx..].to_vec();
169    vertices.push(target);
170
171    let mut edges: Vec<u32> = epath[(start_idx + 1)..].to_vec();
172    edges.push(closing_edge);
173
174    CycleResult { vertices, edges }
175}
176
177/// Build incidence list: for each vertex, list of (neighbor, `edge_id`) pairs.
178fn build_incidence(graph: &Graph, mode: CycleMode) -> IgraphResult<Vec<Vec<(VertexId, u32)>>> {
179    let n = graph.vcount() as usize;
180    let ecount = graph.ecount();
181    let directed = graph.is_directed();
182    let mut inc: Vec<Vec<(VertexId, u32)>> = vec![Vec::new(); n];
183
184    for eid in 0..ecount {
185        #[allow(clippy::cast_possible_truncation)]
186        let eid_u32 = eid as u32;
187        let (src, tgt) = graph.edge(eid_u32)?;
188
189        if !directed || mode == CycleMode::All {
190            inc[src as usize].push((tgt, eid_u32));
191            if src != tgt {
192                inc[tgt as usize].push((src, eid_u32));
193            }
194        } else if mode == CycleMode::Out {
195            inc[src as usize].push((tgt, eid_u32));
196        } else {
197            // CycleMode::In
198            inc[tgt as usize].push((src, eid_u32));
199        }
200    }
201
202    Ok(inc)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_empty_graph() {
211        let g = Graph::with_vertices(0);
212        let r = find_cycle(&g, CycleMode::All).unwrap();
213        assert!(r.vertices.is_empty());
214        assert!(r.edges.is_empty());
215    }
216
217    #[test]
218    fn test_no_edges() {
219        let g = Graph::with_vertices(5);
220        let r = find_cycle(&g, CycleMode::All).unwrap();
221        assert!(r.vertices.is_empty());
222    }
223
224    #[test]
225    fn test_tree_no_cycle() {
226        let mut g = Graph::with_vertices(5);
227        g.add_edge(0, 1).unwrap();
228        g.add_edge(0, 2).unwrap();
229        g.add_edge(1, 3).unwrap();
230        g.add_edge(1, 4).unwrap();
231        let r = find_cycle(&g, CycleMode::All).unwrap();
232        assert!(r.vertices.is_empty());
233    }
234
235    #[test]
236    fn test_triangle_undirected() {
237        let mut g = Graph::with_vertices(3);
238        g.add_edge(0, 1).unwrap();
239        g.add_edge(1, 2).unwrap();
240        g.add_edge(0, 2).unwrap();
241        let r = find_cycle(&g, CycleMode::All).unwrap();
242        assert!(!r.vertices.is_empty());
243        // Cycle should have 3 vertices (first == last) and 3 edges
244        assert_eq!(r.vertices.len(), r.edges.len() + 1);
245        assert_eq!(*r.vertices.first().unwrap(), *r.vertices.last().unwrap());
246    }
247
248    #[test]
249    fn test_directed_cycle() {
250        let mut g = Graph::new(3, true).unwrap();
251        g.add_edge(0, 1).unwrap();
252        g.add_edge(1, 2).unwrap();
253        g.add_edge(2, 0).unwrap();
254        let r = find_cycle(&g, CycleMode::Out).unwrap();
255        assert!(!r.vertices.is_empty());
256        assert_eq!(r.vertices.first(), r.vertices.last());
257    }
258
259    #[test]
260    fn test_dag_no_cycle() {
261        let mut g = Graph::new(4, true).unwrap();
262        g.add_edge(0, 1).unwrap();
263        g.add_edge(0, 2).unwrap();
264        g.add_edge(1, 3).unwrap();
265        g.add_edge(2, 3).unwrap();
266        let r = find_cycle(&g, CycleMode::Out).unwrap();
267        assert!(r.vertices.is_empty());
268    }
269
270    #[test]
271    fn test_directed_cycle_in_mode() {
272        // 0←1←2←0 when followed in reverse
273        let mut g = Graph::new(3, true).unwrap();
274        g.add_edge(0, 1).unwrap();
275        g.add_edge(1, 2).unwrap();
276        g.add_edge(2, 0).unwrap();
277        let r = find_cycle(&g, CycleMode::In).unwrap();
278        assert!(!r.vertices.is_empty());
279        assert_eq!(r.vertices.first(), r.vertices.last());
280    }
281
282    #[test]
283    fn test_self_loop() {
284        let mut g = Graph::with_vertices(3);
285        g.add_edge(0, 1).unwrap();
286        g.add_edge(1, 1).unwrap();
287        // Self-loop: the edge goes from 1 to 1. DFS from 0 visits 1 via edge 0,
288        // then finds the self-loop edge 1 leading back to 1 which is on the path.
289        let r = find_cycle(&g, CycleMode::All).unwrap();
290        assert!(!r.vertices.is_empty());
291    }
292
293    #[test]
294    fn test_undirected_4_cycle() {
295        let mut g = Graph::with_vertices(4);
296        g.add_edge(0, 1).unwrap();
297        g.add_edge(1, 2).unwrap();
298        g.add_edge(2, 3).unwrap();
299        g.add_edge(3, 0).unwrap();
300        let r = find_cycle(&g, CycleMode::All).unwrap();
301        assert!(!r.vertices.is_empty());
302        assert_eq!(r.vertices.first(), r.vertices.last());
303        // A 4-cycle should be found
304        assert!(r.vertices.len() >= 4);
305    }
306
307    #[test]
308    fn test_disconnected_one_has_cycle() {
309        let mut g = Graph::with_vertices(6);
310        // Component 1: tree (0-1-2)
311        g.add_edge(0, 1).unwrap();
312        g.add_edge(1, 2).unwrap();
313        // Component 2: triangle (3-4-5)
314        g.add_edge(3, 4).unwrap();
315        g.add_edge(4, 5).unwrap();
316        g.add_edge(3, 5).unwrap();
317        let r = find_cycle(&g, CycleMode::All).unwrap();
318        assert!(!r.vertices.is_empty());
319    }
320
321    #[test]
322    fn test_cycle_edges_valid() {
323        let mut g = Graph::new(4, true).unwrap();
324        g.add_edge(0, 1).unwrap(); // eid 0
325        g.add_edge(1, 2).unwrap(); // eid 1
326        g.add_edge(2, 3).unwrap(); // eid 2
327        g.add_edge(3, 1).unwrap(); // eid 3
328        let r = find_cycle(&g, CycleMode::Out).unwrap();
329        assert!(!r.vertices.is_empty());
330        // All edge IDs should be valid
331        for &eid in &r.edges {
332            assert!(graph_has_edge(&g, eid));
333        }
334    }
335
336    fn graph_has_edge(g: &Graph, eid: u32) -> bool {
337        g.edge(eid).is_ok()
338    }
339}