Skip to main content

rust_igraph/algorithms/
feedback_vertex_set.rs

1//! Feedback vertex set (ALGO-CY-003).
2//!
3//! A feedback vertex set (FVS) is a set of vertices whose removal makes
4//! the graph acyclic. Finding a minimum FVS is NP-complete on both
5//! directed and undirected graphs.
6//!
7//! We implement a greedy heuristic: repeatedly find a cycle, remove the
8//! vertex with the highest degree from it, and repeat until no cycles
9//! remain. This is O((V+E)·|FVS|) and produces a reasonable
10//! approximation.
11//!
12//! Counterpart of `igraph_feedback_vertex_set` (which uses GLPK IP in C;
13//! here we provide a dependency-free greedy heuristic).
14
15use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
16
17/// Algorithm choice for computing the feedback vertex set.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FvsAlgorithm {
20    /// Greedy heuristic: iteratively remove the highest-degree vertex
21    /// from each discovered cycle.
22    Greedy,
23}
24
25/// Compute a feedback vertex set — a set of vertex IDs whose removal
26/// makes the graph acyclic.
27///
28/// Uses a greedy heuristic that repeatedly finds a cycle and removes
29/// the vertex with the highest degree. Not guaranteed to be minimum.
30///
31/// # Errors
32///
33/// - Returns an error if `weights` length does not match `vcount`.
34/// - Returns an error if weights contain non-finite values.
35///
36/// # Examples
37///
38/// ```
39/// use rust_igraph::{Graph, feedback_vertex_set, FvsAlgorithm};
40///
41/// // Directed cycle 0 → 1 → 2 → 0: removing one vertex breaks it.
42/// let mut g = Graph::new(3, true).unwrap();
43/// g.add_edge(0, 1).unwrap();
44/// g.add_edge(1, 2).unwrap();
45/// g.add_edge(2, 0).unwrap();
46/// let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
47/// assert_eq!(fvs.len(), 1);
48///
49/// // DAG: no feedback vertices needed.
50/// let mut g = Graph::new(3, true).unwrap();
51/// g.add_edge(0, 1).unwrap();
52/// g.add_edge(1, 2).unwrap();
53/// let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
54/// assert!(fvs.is_empty());
55/// ```
56pub fn feedback_vertex_set(
57    graph: &Graph,
58    weights: Option<&[f64]>,
59    _algo: FvsAlgorithm,
60) -> IgraphResult<Vec<VertexId>> {
61    let n = graph.vcount();
62
63    if let Some(w) = weights {
64        if w.len() != n as usize {
65            return Err(IgraphError::InvalidArgument(format!(
66                "weights length {} does not match vcount {}",
67                w.len(),
68                n
69            )));
70        }
71        if !w.iter().all(|x| x.is_finite()) {
72            return Err(IgraphError::InvalidArgument(
73                "weights must be finite".into(),
74            ));
75        }
76    }
77
78    if graph.ecount() == 0 {
79        return Ok(Vec::new());
80    }
81
82    fvs_greedy(graph, weights)
83}
84
85fn find_cycle_avoiding(
86    graph: &Graph,
87    removed: &[bool],
88    directed: bool,
89) -> IgraphResult<Vec<VertexId>> {
90    let n = graph.vcount();
91    let mut seen = vec![0u8; n as usize]; // 0=unseen, 1=ancestor, 2=visited
92
93    let mut stack: Vec<(VertexId, usize)> = Vec::new();
94    let mut path: Vec<VertexId> = Vec::new();
95
96    for start in 0..n {
97        if removed[start as usize] || seen[start as usize] != 0 {
98            continue;
99        }
100
101        stack.clear();
102        stack.push((start, 0));
103        path.clear();
104
105        while let Some(&mut (v, ref mut idx)) = stack.last_mut() {
106            if *idx == 0 {
107                if seen[v as usize] == 1 {
108                    // Found a cycle back to v
109                    let mut cycle = Vec::new();
110                    let pos = path.iter().rposition(|&x| x == v).unwrap_or(0);
111                    for &cv in &path[pos..] {
112                        cycle.push(cv);
113                    }
114                    cycle.push(v);
115                    return Ok(cycle);
116                }
117                if seen[v as usize] == 2 {
118                    stack.pop();
119                    continue;
120                }
121                seen[v as usize] = 1;
122                path.push(v);
123            }
124
125            let neighbors = if directed {
126                graph.out_neighbors_vec(v)?
127            } else {
128                graph.neighbors(v)?
129            };
130
131            let mut found_next = false;
132            while *idx < neighbors.len() {
133                let w = neighbors[*idx];
134                *idx += 1;
135
136                if removed[w as usize] {
137                    continue;
138                }
139
140                if !directed && path.len() >= 2 && w == path[path.len() - 2] {
141                    continue;
142                }
143
144                if seen[w as usize] == 1 {
145                    // Found cycle: path from w's position to current + w
146                    let mut cycle = Vec::new();
147                    let pos = path.iter().rposition(|&x| x == w).unwrap_or(0);
148                    for &cv in &path[pos..] {
149                        cycle.push(cv);
150                    }
151                    return Ok(cycle);
152                }
153
154                if seen[w as usize] == 0 {
155                    stack.push((w, 0));
156                    found_next = true;
157                    break;
158                }
159            }
160
161            if !found_next {
162                seen[v as usize] = 2;
163                path.pop();
164                stack.pop();
165            }
166        }
167    }
168
169    Ok(Vec::new())
170}
171
172#[allow(clippy::cast_possible_truncation)] // degree ≤ vcount which is u32
173fn effective_degree(
174    graph: &Graph,
175    v: VertexId,
176    removed: &[bool],
177    directed: bool,
178) -> IgraphResult<u32> {
179    if directed {
180        let out_n = graph.out_neighbors_vec(v)?;
181        let in_n = graph.in_neighbors_vec(v)?;
182        let out_deg = out_n.iter().filter(|&&w| !removed[w as usize]).count() as u32;
183        let in_deg = in_n.iter().filter(|&&w| !removed[w as usize]).count() as u32;
184        Ok(out_deg.saturating_add(in_deg))
185    } else {
186        let n = graph.neighbors(v)?;
187        Ok(n.iter().filter(|&&w| !removed[w as usize]).count() as u32)
188    }
189}
190
191fn pick_best_vertex(
192    graph: &Graph,
193    cycle: &[VertexId],
194    weights: Option<&[f64]>,
195    removed: &[bool],
196    directed: bool,
197) -> IgraphResult<VertexId> {
198    let mut best_v = cycle[0];
199    if let Some(w) = weights {
200        let mut best_w = w[cycle[0] as usize];
201        for &v in &cycle[1..] {
202            let vw = w[v as usize];
203            if vw > best_w || (vw > best_w - f64::EPSILON && v < best_v) {
204                best_w = vw;
205                best_v = v;
206            }
207        }
208    } else {
209        let mut best_deg = effective_degree(graph, cycle[0], removed, directed)?;
210        for &v in &cycle[1..] {
211            let deg = effective_degree(graph, v, removed, directed)?;
212            if deg > best_deg || (deg == best_deg && v < best_v) {
213                best_deg = deg;
214                best_v = v;
215            }
216        }
217    }
218    Ok(best_v)
219}
220
221fn fvs_greedy(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<Vec<VertexId>> {
222    let n = graph.vcount();
223    let directed = graph.is_directed();
224    let mut removed = vec![false; n as usize];
225    let mut result = Vec::new();
226
227    loop {
228        let cycle = find_cycle_avoiding(graph, &removed, directed)?;
229        if cycle.is_empty() {
230            break;
231        }
232
233        let best_v = pick_best_vertex(graph, &cycle, weights, &removed, directed)?;
234        removed[best_v as usize] = true;
235        result.push(best_v);
236    }
237
238    result.sort_unstable();
239    Ok(result)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn graph_is_acyclic_without(graph: &Graph, fvs: &[VertexId]) -> bool {
247        let n = graph.vcount();
248        let mut removed = vec![false; n as usize];
249        for &v in fvs {
250            removed[v as usize] = true;
251        }
252        let cycle = find_cycle_avoiding(graph, &removed, graph.is_directed()).unwrap();
253        cycle.is_empty()
254    }
255
256    #[test]
257    fn empty_graph() {
258        let g = Graph::with_vertices(0);
259        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
260        assert!(fvs.is_empty());
261    }
262
263    #[test]
264    fn no_edges() {
265        let g = Graph::with_vertices(5);
266        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
267        assert!(fvs.is_empty());
268    }
269
270    #[test]
271    fn dag_no_feedback() {
272        let mut g = Graph::new(4, true).unwrap();
273        g.add_edge(0, 1).unwrap();
274        g.add_edge(1, 2).unwrap();
275        g.add_edge(2, 3).unwrap();
276        g.add_edge(0, 3).unwrap();
277        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
278        assert!(fvs.is_empty());
279    }
280
281    #[test]
282    fn simple_directed_cycle() {
283        let mut g = Graph::new(3, true).unwrap();
284        g.add_edge(0, 1).unwrap();
285        g.add_edge(1, 2).unwrap();
286        g.add_edge(2, 0).unwrap();
287        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
288        assert_eq!(fvs.len(), 1);
289        assert!(graph_is_acyclic_without(&g, &fvs));
290    }
291
292    #[test]
293    fn directed_cycle_4() {
294        let mut g = Graph::new(4, true).unwrap();
295        g.add_edge(0, 1).unwrap();
296        g.add_edge(1, 2).unwrap();
297        g.add_edge(2, 3).unwrap();
298        g.add_edge(3, 0).unwrap();
299        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
300        assert_eq!(fvs.len(), 1);
301        assert!(graph_is_acyclic_without(&g, &fvs));
302    }
303
304    #[test]
305    fn undirected_tree_no_feedback() {
306        let mut g = Graph::with_vertices(5);
307        g.add_edge(0, 1).unwrap();
308        g.add_edge(1, 2).unwrap();
309        g.add_edge(1, 3).unwrap();
310        g.add_edge(3, 4).unwrap();
311        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
312        assert!(fvs.is_empty());
313    }
314
315    #[test]
316    fn undirected_triangle() {
317        let mut g = Graph::with_vertices(3);
318        g.add_edge(0, 1).unwrap();
319        g.add_edge(1, 2).unwrap();
320        g.add_edge(2, 0).unwrap();
321        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
322        assert_eq!(fvs.len(), 1);
323        assert!(graph_is_acyclic_without(&g, &fvs));
324    }
325
326    #[test]
327    fn two_directed_cycles_sharing_vertex() {
328        // 0→1→2→0, 0→3→4→0
329        let mut g = Graph::new(5, true).unwrap();
330        g.add_edge(0, 1).unwrap();
331        g.add_edge(1, 2).unwrap();
332        g.add_edge(2, 0).unwrap();
333        g.add_edge(0, 3).unwrap();
334        g.add_edge(3, 4).unwrap();
335        g.add_edge(4, 0).unwrap();
336        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
337        // Removing vertex 0 breaks both cycles
338        assert!(fvs.len() <= 2);
339        assert!(graph_is_acyclic_without(&g, &fvs));
340    }
341
342    #[test]
343    fn bidirectional_edges() {
344        let mut g = Graph::new(2, true).unwrap();
345        g.add_edge(0, 1).unwrap();
346        g.add_edge(1, 0).unwrap();
347        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
348        assert_eq!(fvs.len(), 1);
349        assert!(graph_is_acyclic_without(&g, &fvs));
350    }
351
352    #[test]
353    fn self_loop_directed() {
354        let mut g = Graph::new(3, true).unwrap();
355        g.add_edge(0, 0).unwrap();
356        g.add_edge(0, 1).unwrap();
357        g.add_edge(1, 2).unwrap();
358        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
359        assert_eq!(fvs.len(), 1);
360        assert!(fvs.contains(&0));
361        assert!(graph_is_acyclic_without(&g, &fvs));
362    }
363
364    #[test]
365    fn self_loop_undirected() {
366        let mut g = Graph::with_vertices(3);
367        g.add_edge(0, 0).unwrap();
368        g.add_edge(0, 1).unwrap();
369        g.add_edge(1, 2).unwrap();
370        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
371        assert_eq!(fvs.len(), 1);
372        assert!(fvs.contains(&0));
373    }
374
375    #[test]
376    fn undirected_k4() {
377        let mut g = Graph::with_vertices(4);
378        for u in 0..4u32 {
379            for v in (u + 1)..4 {
380                g.add_edge(u, v).unwrap();
381            }
382        }
383        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
384        // K4 needs at least 2 vertex removals to become acyclic
385        assert!(fvs.len() >= 2);
386        assert!(graph_is_acyclic_without(&g, &fvs));
387    }
388
389    #[test]
390    fn weighted_picks_heaviest() {
391        let mut g = Graph::new(3, true).unwrap();
392        g.add_edge(0, 1).unwrap();
393        g.add_edge(1, 2).unwrap();
394        g.add_edge(2, 0).unwrap();
395        // Vertex 1 has highest weight — should be picked first
396        let weights = vec![1.0, 10.0, 1.0];
397        let fvs = feedback_vertex_set(&g, Some(&weights), FvsAlgorithm::Greedy).unwrap();
398        assert_eq!(fvs.len(), 1);
399        assert_eq!(fvs[0], 1);
400    }
401
402    #[test]
403    fn invalid_weights_length() {
404        let g = Graph::with_vertices(3);
405        let weights = vec![1.0]; // wrong length
406        assert!(feedback_vertex_set(&g, Some(&weights), FvsAlgorithm::Greedy).is_err());
407    }
408
409    #[test]
410    fn nan_weights_rejected() {
411        let mut g = Graph::with_vertices(2);
412        g.add_edge(0, 1).unwrap();
413        let weights = vec![f64::NAN, 1.0];
414        assert!(feedback_vertex_set(&g, Some(&weights), FvsAlgorithm::Greedy).is_err());
415    }
416
417    #[test]
418    fn isolated_vertices() {
419        let g = Graph::new(5, true).unwrap();
420        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
421        assert!(fvs.is_empty());
422    }
423
424    #[test]
425    fn complex_directed() {
426        // Two overlapping cycles: 0→1→2→0 and 2→3→4→2
427        let mut g = Graph::new(5, true).unwrap();
428        g.add_edge(0, 1).unwrap();
429        g.add_edge(1, 2).unwrap();
430        g.add_edge(2, 0).unwrap();
431        g.add_edge(2, 3).unwrap();
432        g.add_edge(3, 4).unwrap();
433        g.add_edge(4, 2).unwrap();
434        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
435        // Removing vertex 2 breaks both cycles
436        assert!(fvs.len() <= 2);
437        assert!(graph_is_acyclic_without(&g, &fvs));
438    }
439
440    #[test]
441    fn sorted_output() {
442        let mut g = Graph::new(6, true).unwrap();
443        g.add_edge(0, 1).unwrap();
444        g.add_edge(1, 0).unwrap();
445        g.add_edge(2, 3).unwrap();
446        g.add_edge(3, 2).unwrap();
447        g.add_edge(4, 5).unwrap();
448        g.add_edge(5, 4).unwrap();
449        let fvs = feedback_vertex_set(&g, None, FvsAlgorithm::Greedy).unwrap();
450        assert_eq!(fvs.len(), 3);
451        // Verify sorted
452        for i in 1..fvs.len() {
453            assert!(fvs[i] > fvs[i - 1]);
454        }
455        assert!(graph_is_acyclic_without(&g, &fvs));
456    }
457}