Skip to main content

rust_igraph/algorithms/
minimum_cycle_basis.rs

1//! Minimum weight cycle basis (ALGO-CY-004).
2//!
3//! Computes a minimum weight cycle basis using a modified Horton's
4//! algorithm. Candidate cycles are generated via BFS from each vertex
5//! of degree ≥ 3, sorted by length, deduplicated, then filtered via
6//! Gaussian elimination over GF(2) to select linearly independent
7//! cycles of minimum total weight.
8//!
9//! Edge directions are ignored. Multi-edges and self-loops are
10//! supported.
11//!
12//! Counterpart of `igraph_minimum_cycle_basis`.
13
14use std::collections::VecDeque;
15
16use crate::algorithms::connectivity::components::connected_components;
17use crate::core::graph::EdgeId;
18use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
19
20/// Compute the minimum weight cycle basis of a graph.
21///
22/// Returns a list of cycles, where each cycle is a `Vec<EdgeId>`
23/// listing the edge IDs forming that cycle. The cycle basis has the
24/// smallest possible total weight (sum of cycle lengths for unweighted
25/// graphs).
26///
27/// If `bfs_cutoff` is `Some(k)`, only cycles of length at most
28/// `2*k + 1` are guaranteed to be minimum-weight. Longer cycles may
29/// still be included to complete the basis if `complete` is true.
30///
31/// If `complete` is true, a complete cycle basis spanning the entire
32/// cycle space is returned. If false and `bfs_cutoff` is set, only
33/// short cycles are returned (the result may not span the full cycle
34/// space).
35///
36/// Edge directions are ignored (the graph is treated as undirected).
37///
38/// # Errors
39///
40/// Returns an error if internal computation fails.
41///
42/// # Examples
43///
44/// ```
45/// use rust_igraph::{Graph, minimum_cycle_basis};
46///
47/// // Triangle: one cycle of length 3.
48/// let mut g = Graph::with_vertices(3);
49/// g.add_edge(0, 1).unwrap();
50/// g.add_edge(1, 2).unwrap();
51/// g.add_edge(2, 0).unwrap();
52/// let cycles = minimum_cycle_basis(&g, None, true).unwrap();
53/// assert_eq!(cycles.len(), 1);
54/// assert_eq!(cycles[0].len(), 3);
55///
56/// // K4: 3 independent cycles, each of length 3.
57/// let mut g = Graph::with_vertices(4);
58/// g.add_edge(0, 1).unwrap();
59/// g.add_edge(0, 2).unwrap();
60/// g.add_edge(0, 3).unwrap();
61/// g.add_edge(1, 2).unwrap();
62/// g.add_edge(1, 3).unwrap();
63/// g.add_edge(2, 3).unwrap();
64/// let cycles = minimum_cycle_basis(&g, None, true).unwrap();
65/// assert_eq!(cycles.len(), 3);
66/// // All minimum cycles in K4 are triangles (length 3)
67/// for c in &cycles {
68///     assert_eq!(c.len(), 3);
69/// }
70/// ```
71pub fn minimum_cycle_basis(
72    graph: &Graph,
73    bfs_cutoff: Option<u32>,
74    complete: bool,
75) -> IgraphResult<Vec<Vec<EdgeId>>> {
76    let n = graph.vcount();
77    let ecount = graph.ecount();
78
79    if n == 0 || ecount == 0 {
80        return Ok(Vec::new());
81    }
82
83    let adj = build_incident_all(graph)?;
84
85    let cc = connected_components(graph)?;
86    let num_components = cc.count;
87
88    #[allow(clippy::cast_possible_truncation)]
89    let rank = (ecount as u32)
90        .saturating_sub(n)
91        .saturating_add(num_components);
92
93    if rank == 0 {
94        return Ok(Vec::new());
95    }
96
97    let degrees = compute_degrees(&adj, n);
98
99    let mut visited: Vec<u32> = vec![0; n as usize];
100    let mut candidates: Vec<Vec<EdgeId>> = Vec::new();
101    let mut mark: u32 = 0;
102
103    for i in 0..n {
104        let deg = degrees[i as usize];
105        let vis = visited[i as usize] % 3 != 0;
106
107        if deg <= 1 || (vis && deg < 3) {
108            continue;
109        }
110
111        let cutoff = if vis || !complete { bfs_cutoff } else { None };
112
113        fundamental_cycles_bfs_horton(graph, &adj, &mut candidates, i, cutoff, &mut visited, mark)?;
114        mark = mark.checked_add(3).ok_or_else(|| {
115            IgraphError::InvalidArgument("mark overflow in minimum_cycle_basis".into())
116        })?;
117    }
118
119    // Sort candidates by size, then lexicographically
120    for c in &mut candidates {
121        c.sort_unstable();
122    }
123    candidates.sort_unstable_by(cycle_cmp);
124    candidates.dedup();
125
126    // Gaussian elimination over GF(2) to find independent cycles
127    let mut result: Vec<Vec<EdgeId>> = Vec::with_capacity(rank as usize);
128    let mut reduced_matrix: Vec<Vec<EdgeId>> = Vec::new();
129
130    for cycle in &candidates {
131        let independent = gaussian_elimination_check(&mut reduced_matrix, cycle);
132        if independent {
133            result.push(cycle.clone());
134        }
135        if reduced_matrix.len() == rank as usize {
136            break;
137        }
138    }
139
140    Ok(result)
141}
142
143fn build_incident_all(graph: &Graph) -> IgraphResult<Vec<Vec<(EdgeId, VertexId)>>> {
144    let n = graph.vcount() as usize;
145    let mut adj: Vec<Vec<(EdgeId, VertexId)>> = vec![Vec::new(); n];
146
147    for eid in 0..graph.ecount() {
148        #[allow(clippy::cast_possible_truncation)]
149        let eid32 = eid as EdgeId;
150        let (from, to) = graph.edge(eid32)?;
151        if from == to {
152            adj[from as usize].push((eid32, to));
153        } else {
154            adj[from as usize].push((eid32, to));
155            adj[to as usize].push((eid32, from));
156        }
157    }
158
159    Ok(adj)
160}
161
162fn compute_degrees(adj: &[Vec<(EdgeId, VertexId)>], n: u32) -> Vec<u32> {
163    let mut degrees = vec![0u32; n as usize];
164    for (i, neighbors) in adj.iter().enumerate() {
165        #[allow(clippy::cast_possible_truncation)]
166        {
167            degrees[i] = neighbors.len() as u32;
168        }
169    }
170    degrees
171}
172
173fn fundamental_cycles_bfs_horton(
174    graph: &Graph,
175    adj: &[Vec<(EdgeId, VertexId)>],
176    result: &mut Vec<Vec<EdgeId>>,
177    start: VertexId,
178    bfs_cutoff: Option<u32>,
179    visited: &mut [u32],
180    mark: u32,
181) -> IgraphResult<()> {
182    let n = graph.vcount() as usize;
183    let mut pred_edge: Vec<i64> = vec![-1; n];
184    let mut queue: VecDeque<(VertexId, u32)> = VecDeque::new();
185
186    visited[start as usize] = mark + 1;
187    queue.push_back((start, 0));
188
189    while let Some((v, vdist)) = queue.pop_front() {
190        for &(eid, u) in &adj[v as usize] {
191            if i64::from(eid) == pred_edge[v as usize] {
192                continue;
193            }
194
195            let u_state = visited[u as usize];
196
197            if u_state == mark + 2 {
198                continue;
199            }
200            if u_state == mark + 1 {
201                let cycle = trace_cycle(graph, pred_edge.as_slice(), eid, u, v)?;
202                result.push(cycle);
203            } else if bfs_cutoff.is_none() || vdist < bfs_cutoff.unwrap_or(0) {
204                visited[u as usize] = mark + 1;
205                pred_edge[u as usize] = i64::from(eid);
206                queue.push_back((u, vdist.saturating_add(1)));
207            }
208        }
209
210        visited[v as usize] = mark + 2;
211    }
212
213    Ok(())
214}
215
216fn trace_cycle(
217    graph: &Graph,
218    pred_edge: &[i64],
219    closing_edge: EdgeId,
220    u: VertexId,
221    v: VertexId,
222) -> IgraphResult<Vec<EdgeId>> {
223    let mut u_back: Vec<EdgeId> = Vec::new();
224    let mut v_back: Vec<EdgeId> = vec![closing_edge];
225
226    let mut up = u;
227    let mut vp = v;
228
229    loop {
230        if up == vp {
231            break;
232        }
233
234        let u_pred = pred_edge[up as usize];
235        if u_pred < 0 {
236            break;
237        }
238        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
239        let u_eid = u_pred as EdgeId;
240        u_back.push(u_eid);
241        up = graph.edge_other(u_eid, up)?;
242
243        if up == vp {
244            break;
245        }
246
247        let v_pred = pred_edge[vp as usize];
248        if v_pred < 0 {
249            break;
250        }
251        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
252        let v_eid = v_pred as EdgeId;
253        v_back.push(v_eid);
254        vp = graph.edge_other(v_eid, vp)?;
255    }
256
257    let mut cycle = v_back;
258    cycle.extend(u_back.into_iter().rev());
259    Ok(cycle)
260}
261
262fn cycle_cmp(a: &Vec<EdgeId>, b: &Vec<EdgeId>) -> std::cmp::Ordering {
263    a.len().cmp(&b.len()).then_with(|| a.cmp(b))
264}
265
266/// Symmetric difference of two sorted edge-id vectors (GF(2) addition).
267fn cycle_add(a: &[EdgeId], b: &[EdgeId]) -> Vec<EdgeId> {
268    let mut result = Vec::with_capacity(a.len() + b.len());
269    let mut ia = 0;
270    let mut ib = 0;
271
272    while ia < a.len() && ib < b.len() {
273        match a[ia].cmp(&b[ib]) {
274            std::cmp::Ordering::Less => {
275                result.push(a[ia]);
276                ia += 1;
277            }
278            std::cmp::Ordering::Equal => {
279                ia += 1;
280                ib += 1;
281            }
282            std::cmp::Ordering::Greater => {
283                result.push(b[ib]);
284                ib += 1;
285            }
286        }
287    }
288
289    result.extend_from_slice(&a[ia..]);
290    result.extend_from_slice(&b[ib..]);
291    result
292}
293
294/// Check if `cycle` is linearly independent of the reduced matrix.
295/// If independent, inserts it into the matrix and returns true.
296fn gaussian_elimination_check(reduced_matrix: &mut Vec<Vec<EdgeId>>, cycle: &[EdgeId]) -> bool {
297    let mut work: Vec<EdgeId> = cycle.to_vec();
298
299    let mut insert_pos = 0;
300    for (i, row) in reduced_matrix.iter().enumerate() {
301        if work.is_empty() {
302            return false;
303        }
304        match row[0].cmp(&work[0]) {
305            std::cmp::Ordering::Less => {
306                insert_pos = i + 1;
307            }
308            std::cmp::Ordering::Equal => {
309                work = cycle_add(row, &work);
310                if work.is_empty() {
311                    return false;
312                }
313                insert_pos = i + 1;
314            }
315            std::cmp::Ordering::Greater => {
316                insert_pos = i;
317                break;
318            }
319        }
320    }
321
322    if work.is_empty() {
323        return false;
324    }
325
326    if insert_pos >= reduced_matrix.len() {
327        reduced_matrix.push(work);
328    } else {
329        reduced_matrix.insert(insert_pos, work);
330    }
331    true
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn empty_graph() {
340        let g = Graph::with_vertices(0);
341        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
342        assert!(cycles.is_empty());
343    }
344
345    #[test]
346    fn tree_no_cycles() {
347        let mut g = Graph::with_vertices(5);
348        g.add_edge(0, 1).unwrap();
349        g.add_edge(0, 2).unwrap();
350        g.add_edge(1, 3).unwrap();
351        g.add_edge(1, 4).unwrap();
352        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
353        assert!(cycles.is_empty());
354    }
355
356    #[test]
357    fn single_triangle() {
358        let mut g = Graph::with_vertices(3);
359        g.add_edge(0, 1).unwrap();
360        g.add_edge(1, 2).unwrap();
361        g.add_edge(2, 0).unwrap();
362        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
363        assert_eq!(cycles.len(), 1);
364        assert_eq!(cycles[0].len(), 3);
365    }
366
367    #[test]
368    fn k4_all_triangles() {
369        let mut g = Graph::with_vertices(4);
370        g.add_edge(0, 1).unwrap(); // 0
371        g.add_edge(0, 2).unwrap(); // 1
372        g.add_edge(0, 3).unwrap(); // 2
373        g.add_edge(1, 2).unwrap(); // 3
374        g.add_edge(1, 3).unwrap(); // 4
375        g.add_edge(2, 3).unwrap(); // 5
376        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
377        // K4: rank = 6 - 4 + 1 = 3, all minimum cycles are triangles
378        assert_eq!(cycles.len(), 3);
379        for c in &cycles {
380            assert_eq!(c.len(), 3);
381        }
382    }
383
384    #[test]
385    fn cycle_5_single_cycle() {
386        let mut g = Graph::with_vertices(5);
387        g.add_edge(0, 1).unwrap();
388        g.add_edge(1, 2).unwrap();
389        g.add_edge(2, 3).unwrap();
390        g.add_edge(3, 4).unwrap();
391        g.add_edge(4, 0).unwrap();
392        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
393        // rank = 5 - 5 + 1 = 1
394        assert_eq!(cycles.len(), 1);
395        assert_eq!(cycles[0].len(), 5);
396    }
397
398    #[test]
399    fn two_triangles_sharing_edge() {
400        // 0-1-2-0 and 1-2-3-1
401        let mut g = Graph::with_vertices(4);
402        g.add_edge(0, 1).unwrap(); // 0
403        g.add_edge(1, 2).unwrap(); // 1
404        g.add_edge(2, 0).unwrap(); // 2
405        g.add_edge(2, 3).unwrap(); // 3
406        g.add_edge(3, 1).unwrap(); // 4
407        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
408        // rank = 5 - 4 + 1 = 2, both minimum cycles are triangles
409        assert_eq!(cycles.len(), 2);
410        for c in &cycles {
411            assert_eq!(c.len(), 3);
412        }
413    }
414
415    #[test]
416    fn two_components() {
417        let mut g = Graph::with_vertices(6);
418        // Triangle 0-1-2
419        g.add_edge(0, 1).unwrap();
420        g.add_edge(1, 2).unwrap();
421        g.add_edge(2, 0).unwrap();
422        // Triangle 3-4-5
423        g.add_edge(3, 4).unwrap();
424        g.add_edge(4, 5).unwrap();
425        g.add_edge(5, 3).unwrap();
426        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
427        // rank = 6 - 6 + 2 = 2
428        assert_eq!(cycles.len(), 2);
429    }
430
431    #[test]
432    fn cycle_rank_formula() {
433        // Connected graph: rank = |E| - |V| + 1
434        let mut g = Graph::with_vertices(5);
435        g.add_edge(0, 1).unwrap();
436        g.add_edge(1, 2).unwrap();
437        g.add_edge(2, 3).unwrap();
438        g.add_edge(3, 4).unwrap();
439        g.add_edge(4, 0).unwrap();
440        g.add_edge(0, 2).unwrap();
441        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
442        // rank = 6 - 5 + 1 = 2
443        assert_eq!(cycles.len(), 2);
444    }
445
446    #[test]
447    fn minimum_weight_property() {
448        // Graph with triangles and a longer cycle
449        // 0-1-2-0 (triangle, weight 3)
450        // 0-1-3-4-0 (4-cycle, weight 4)
451        let mut g = Graph::with_vertices(5);
452        g.add_edge(0, 1).unwrap(); // 0
453        g.add_edge(1, 2).unwrap(); // 1
454        g.add_edge(2, 0).unwrap(); // 2
455        g.add_edge(1, 3).unwrap(); // 3
456        g.add_edge(3, 4).unwrap(); // 4
457        g.add_edge(4, 0).unwrap(); // 5
458        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
459        // rank = 6 - 5 + 1 = 2
460        assert_eq!(cycles.len(), 2);
461        // The minimum basis should prefer shorter cycles
462        // Both cycles should be length 3 or 4, with total weight minimized
463        let total_weight: usize = cycles.iter().map(Vec::len).sum();
464        assert!(total_weight <= 8); // triangle(3) + 4-cycle(4) or two 4-cycles
465    }
466
467    #[test]
468    fn self_loop() {
469        let mut g = Graph::with_vertices(2);
470        g.add_edge(0, 0).unwrap(); // self-loop
471        g.add_edge(0, 1).unwrap();
472        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
473        assert_eq!(cycles.len(), 1);
474        assert_eq!(cycles[0].len(), 1);
475    }
476
477    #[test]
478    fn multi_edge() {
479        let mut g = Graph::with_vertices(2);
480        g.add_edge(0, 1).unwrap();
481        g.add_edge(0, 1).unwrap();
482        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
483        assert_eq!(cycles.len(), 1);
484        assert_eq!(cycles[0].len(), 2);
485    }
486
487    #[test]
488    fn directed_treated_as_undirected() {
489        let mut g = Graph::new(3, true).unwrap();
490        g.add_edge(0, 1).unwrap();
491        g.add_edge(1, 2).unwrap();
492        g.add_edge(2, 0).unwrap();
493        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
494        assert_eq!(cycles.len(), 1);
495    }
496
497    #[test]
498    fn bfs_cutoff_complete() {
499        // With cutoff and complete=true, should still get a full basis
500        let mut g = Graph::with_vertices(4);
501        g.add_edge(0, 1).unwrap();
502        g.add_edge(0, 2).unwrap();
503        g.add_edge(0, 3).unwrap();
504        g.add_edge(1, 2).unwrap();
505        g.add_edge(1, 3).unwrap();
506        g.add_edge(2, 3).unwrap();
507        let cycles = minimum_cycle_basis(&g, Some(1), true).unwrap();
508        assert_eq!(cycles.len(), 3);
509    }
510
511    #[test]
512    fn isolated_vertices() {
513        let g = Graph::with_vertices(10);
514        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
515        assert!(cycles.is_empty());
516    }
517
518    #[test]
519    fn single_vertex() {
520        let g = Graph::with_vertices(1);
521        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
522        assert!(cycles.is_empty());
523    }
524
525    #[test]
526    fn petersen_graph_rank() {
527        // Petersen graph: 10 vertices, 15 edges, rank = 15 - 10 + 1 = 6
528        // All minimum cycles are 5-cycles (girth = 5)
529        let mut g = Graph::with_vertices(10);
530        // Outer cycle
531        g.add_edge(0, 1).unwrap();
532        g.add_edge(1, 2).unwrap();
533        g.add_edge(2, 3).unwrap();
534        g.add_edge(3, 4).unwrap();
535        g.add_edge(4, 0).unwrap();
536        // Inner pentagram
537        g.add_edge(5, 7).unwrap();
538        g.add_edge(7, 9).unwrap();
539        g.add_edge(9, 6).unwrap();
540        g.add_edge(6, 8).unwrap();
541        g.add_edge(8, 5).unwrap();
542        // Rungs
543        g.add_edge(0, 5).unwrap();
544        g.add_edge(1, 6).unwrap();
545        g.add_edge(2, 7).unwrap();
546        g.add_edge(3, 8).unwrap();
547        g.add_edge(4, 9).unwrap();
548        let cycles = minimum_cycle_basis(&g, None, true).unwrap();
549        assert_eq!(cycles.len(), 6);
550        // All minimum cycles in Petersen graph have length 5
551        for c in &cycles {
552            assert_eq!(c.len(), 5);
553        }
554    }
555}