diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index e00c06e36..2006b57b1 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -55,6 +55,8 @@ "ClosestVectorProblem": [Closest Vector Problem], "RuralPostman": [Rural Postman], "LongestCommonSubsequence": [Longest Common Subsequence], + "SubsetSum": [Subset Sum], + "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], "SubgraphIsomorphism": [Subgraph Isomorphism], "SubsetSum": [Subset Sum], @@ -1015,6 +1017,14 @@ Biclique Cover is equivalent to factoring the biadjacency matrix $M$ of the bipa *Example.* Let $A = {3, 7, 1, 8, 2, 4}$ ($n = 6$) and target $B = 11$. Selecting $A' = {3, 8}$ gives sum $3 + 8 = 11 = B$. Another solution: $A' = {7, 4}$ with sum $7 + 4 = 11 = B$. ] +#problem-def("MinimumFeedbackArcSet")[ + Given a directed graph $G = (V, A)$, find a minimum-size subset $A' subset.eq A$ such that $G - A'$ is a directed acyclic graph (DAG). Equivalently, $A'$ must contain at least one arc from every directed cycle in $G$. +][ + Feedback Arc Set (FAS) is a classical NP-complete problem from Karp's original list @karp1972 (via transformation from Vertex Cover, as presented in Garey & Johnson GT8). The problem arises in ranking aggregation, sports scheduling, deadlock avoidance, and causal inference. Unlike the undirected analogue (which is trivially polynomial --- the number of non-tree edges in a spanning forest), the directed version is NP-hard due to the richer structure of directed cycles. The best known exact algorithm uses dynamic programming over vertex subsets in $O^*(2^n)$ time, generalizing the Held--Karp TSP technique to vertex ordering problems @bodlaender2012. FAS is fixed-parameter tractable with parameter $k = |A'|$: an $O(4^k dot k! dot n^(O(1)))$ algorithm exists via iterative compression @chen2008. Polynomial-time solvable for planar digraphs via the Lucchesi--Younger theorem @lucchesi1978. + + *Example.* Consider $G$ with $V = {0, 1, 2, 3, 4, 5}$ and arcs $(0 arrow 1), (1 arrow 2), (2 arrow 0), (1 arrow 3), (3 arrow 4), (4 arrow 1), (2 arrow 5), (5 arrow 3), (3 arrow 0)$. This graph contains four directed cycles: $0 arrow 1 arrow 2 arrow 0$, $1 arrow 3 arrow 4 arrow 1$, $0 arrow 1 arrow 3 arrow 0$, and $2 arrow 5 arrow 3 arrow 0 arrow 1 arrow 2$. Removing $A' = {(0 arrow 1), (3 arrow 4)}$ breaks all four cycles (vertex 0 becomes a sink in the residual graph), giving a minimum FAS of size 2. +] + // Completeness check: warn about problem types in JSON but missing from paper #{ let json-models = { diff --git a/docs/paper/references.bib b/docs/paper/references.bib index ba7e02393..94dcd02f1 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -477,3 +477,36 @@ @article{cygan2014 note = {Conference version: STOC 2014}, doi = {10.1137/140990255} } + +@article{bodlaender2012, + author = {Hans L. Bodlaender and Fedor V. Fomin and Arie M. C. A. Koster and Dieter Kratsch and Dimitrios M. Thilikos}, + title = {A Note on Exact Algorithms for Vertex Ordering Problems on Graphs}, + journal = {Theory of Computing Systems}, + volume = {50}, + number = {3}, + pages = {420--432}, + year = {2012}, + doi = {10.1007/s00224-011-9312-0} +} + +@article{chen2008, + author = {Jianer Chen and Yang Liu and Songjian Lu and Barry O'Sullivan and Igor Razgon}, + title = {A Fixed-Parameter Algorithm for the Directed Feedback Vertex Set Problem}, + journal = {Journal of the ACM}, + volume = {55}, + number = {5}, + pages = {1--19}, + year = {2008}, + doi = {10.1145/1411509.1411511} +} + +@article{lucchesi1978, + author = {Cl\'audio L. Lucchesi and Daniel H. Younger}, + title = {A Minimax Theorem for Directed Graphs}, + journal = {Journal of the London Mathematical Society}, + volume = {s2-17}, + number = {3}, + pages = {369--374}, + year = {1978}, + doi = {10.1112/jlms/s2-17.3.369} +} diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 13a2d4cc6..ae65be85e 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -222,6 +222,7 @@ Flags by problem type: RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound SubgraphIsomorphism --graph (host), --pattern (pattern) LCS --strings + FAS --arcs [--weights] [--num-vertices] FVS --arcs [--weights] [--num-vertices] ILP, CircuitSAT (via reduction only) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 2aa5e2a55..d405d7339 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -68,6 +68,7 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { "u64" => "integer", "i64" => "integer", "Vec" => "comma-separated integers: 3,7,1,8", + "DirectedGraph" => "directed arcs: 0>1,1>2,2>0", _ => "value", } } @@ -94,6 +95,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "PartitionIntoTriangles" => "--graph 0-1,1-2,0-2", "Factoring" => "--target 15 --m 4 --n 4", + "MinimumFeedbackArcSet" => "--arcs \"0>1,1>2,2>0\"", "RuralPostman" => { "--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2 --bound 4" } @@ -122,6 +124,10 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { if graph_type == Some("UnitDiskGraph") { eprintln!(" --{:<16} Distance threshold [default: 1.0]", "radius"); } + } else if field.type_name == "DirectedGraph" { + // DirectedGraph fields use --arcs, not --graph + let hint = type_format_hint(&field.type_name, graph_type); + eprintln!(" --{:<16} {} ({})", "arcs", field.description, hint); } else { let hint = type_format_hint(&field.type_name, graph_type); eprintln!( @@ -542,6 +548,22 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MinimumFeedbackArcSet + "MinimumFeedbackArcSet" => { + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "MinimumFeedbackArcSet requires --arcs\n\n\ + Usage: pred create FAS --arcs \"0>1,1>2,2>0\" [--weights 1,1,1] [--num-vertices N]" + ) + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; + let weights = parse_arc_weights(args, num_arcs)?; + ( + ser(MinimumFeedbackArcSet::new(graph, weights))?, + resolved_variant.clone(), + ) + } + // SubgraphIsomorphism "SubgraphIsomorphism" => { let (host_graph, _) = parse_graph(args).map_err(|e| { @@ -606,47 +628,14 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // MinimumFeedbackVertexSet "MinimumFeedbackVertexSet" => { - let arcs_str = args.arcs.as_ref().ok_or_else(|| { + let arcs_str = args.arcs.as_deref().ok_or_else(|| { anyhow::anyhow!( "MinimumFeedbackVertexSet requires --arcs\n\n\ Usage: pred create FVS --arcs \"0>1,1>2,2>0\" [--weights 1,1,1] [--num-vertices N]" ) })?; - let arcs: Vec<(usize, usize)> = arcs_str - .split(',') - .map(|s| { - let parts: Vec<&str> = s.split('>').collect(); - anyhow::ensure!( - parts.len() == 2, - "Invalid arc format '{}', expected 'u>v'", - s - ); - Ok(( - parts[0].trim().parse::()?, - parts[1].trim().parse::()?, - )) - }) - .collect::>>()?; - let inferred_num_v = arcs - .iter() - .flat_map(|&(u, v)| [u, v]) - .max() - .map(|m| m + 1) - .unwrap_or(0); - let num_v = match args.num_vertices { - Some(user_num_v) => { - anyhow::ensure!( - user_num_v >= inferred_num_v, - "--num-vertices ({}) is too small for the arcs: need at least {} to cover vertices up to {}", - user_num_v, - inferred_num_v, - inferred_num_v.saturating_sub(1), - ); - user_num_v - } - None => inferred_num_v, - }; - let graph = DirectedGraph::new(num_v, arcs); + let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; + let num_v = graph.num_vertices(); let weights = parse_vertex_weights(args, num_v)?; ( ser(MinimumFeedbackVertexSet::new(graph, weights))?, @@ -1035,6 +1024,74 @@ fn parse_matrix(args: &CreateArgs) -> Result>> { .collect() } +/// Parse `--arcs` as directed arc pairs and build a `DirectedGraph`. +/// +/// Returns `(graph, num_arcs)`. Infers vertex count from arc endpoints +/// unless `num_vertices` is provided (which must be >= inferred count). +/// E.g., "0>1,1>2,2>0" +fn parse_directed_graph( + arcs_str: &str, + num_vertices: Option, +) -> Result<(DirectedGraph, usize)> { + let arcs: Vec<(usize, usize)> = arcs_str + .split(',') + .map(|pair| { + let parts: Vec<&str> = pair.trim().split('>').collect(); + if parts.len() != 2 { + bail!( + "Invalid arc '{}': expected format u>v (e.g., 0>1)", + pair.trim() + ); + } + let u: usize = parts[0].parse()?; + let v: usize = parts[1].parse()?; + Ok((u, v)) + }) + .collect::>>()?; + let inferred_num_v = arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|m| m + 1) + .unwrap_or(0); + let num_v = match num_vertices { + Some(user_num_v) => { + anyhow::ensure!( + user_num_v >= inferred_num_v, + "--num-vertices ({}) is too small for the arcs: need at least {} to cover vertices up to {}", + user_num_v, + inferred_num_v, + inferred_num_v.saturating_sub(1), + ); + user_num_v + } + None => inferred_num_v, + }; + let num_arcs = arcs.len(); + Ok((DirectedGraph::new(num_v, arcs), num_arcs)) +} + +/// Parse `--weights` as arc weights (i32), defaulting to all 1s. +fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { + match &args.weights { + Some(w) => { + let weights: Vec = w + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if weights.len() != num_arcs { + bail!( + "Expected {} arc weights but got {}", + num_arcs, + weights.len() + ); + } + Ok(weights) + } + None => Ok(vec![1i32; num_arcs]), + } +} + /// Handle `pred create --random ...` fn create_random( args: &CreateArgs, diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 108b85fba..6291ce928 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -252,6 +252,7 @@ pub fn load_problem( "LongestCommonSubsequence" => deser_opt::(data), "MinimumFeedbackVertexSet" => deser_opt::>(data), "SubsetSum" => deser_sat::(data), + "MinimumFeedbackArcSet" => deser_opt::>(data), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } } @@ -319,6 +320,7 @@ pub fn serialize_any_problem( "LongestCommonSubsequence" => try_ser::(any), "MinimumFeedbackVertexSet" => try_ser::>(any), "SubsetSum" => try_ser::(any), + "MinimumFeedbackArcSet" => try_ser::>(any), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } } diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 998fc75c8..f0ff726e2 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -24,6 +24,7 @@ pub const ALIASES: &[(&str, &str)] = &[ ("LCS", "LongestCommonSubsequence"), ("MaxMatching", "MaximumMatching"), ("FVS", "MinimumFeedbackVertexSet"), + ("FAS", "MinimumFeedbackArcSet"), ]; /// Resolve a short alias to the canonical problem name. @@ -61,6 +62,7 @@ pub fn resolve_alias(input: &str) -> String { "partitionintotriangles" => "PartitionIntoTriangles".to_string(), "lcs" | "longestcommonsubsequence" => "LongestCommonSubsequence".to_string(), "fvs" | "minimumfeedbackvertexset" => "MinimumFeedbackVertexSet".to_string(), + "fas" | "minimumfeedbackarcset" => "MinimumFeedbackArcSet".to_string(), "subsetsum" => "SubsetSum".to_string(), _ => input.to_string(), // pass-through for exact names } diff --git a/src/lib.rs b/src/lib.rs index 88f2f02e7..d8b79fa74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,7 +45,7 @@ pub mod prelude { }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, + MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumVertexCover, PartitionIntoTriangles, RuralPostman, TravelingSalesman, }; pub use crate::models::misc::{ diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs new file mode 100644 index 000000000..d7c92085f --- /dev/null +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -0,0 +1,179 @@ +//! Minimum Feedback Arc Set problem implementation. +//! +//! The Feedback Arc Set problem asks for a minimum-weight subset of arcs +//! whose removal makes a directed graph acyclic (a DAG). + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::DirectedGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, SolutionSize, WeightElement}; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "MinimumFeedbackArcSet", + module_path: module_path!(), + description: "Find minimum weight feedback arc set in a directed graph", + fields: &[ + FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Arc weights w: A -> R" }, + ], + } +} + +/// The Minimum Feedback Arc Set problem. +/// +/// Given a directed graph G = (V, A) and weights w_a for each arc, +/// find a subset A' ⊆ A such that: +/// - Removing A' from G yields a directed acyclic graph (DAG) +/// - The total weight Σ_{a ∈ A'} w_a is minimized +/// +/// # Variables +/// +/// One binary variable per arc: x_a = 1 means arc a is in the feedback arc set (removed). +/// The configuration space has dimension m = |A|. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::MinimumFeedbackArcSet; +/// use problemreductions::topology::DirectedGraph; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // Directed cycle: 0->1->2->0 +/// let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); +/// let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); +/// +/// // Solve with brute force +/// let solver = BruteForce::new(); +/// let solution = solver.find_best(&problem).unwrap(); +/// +/// // Minimum FAS has size 1 (remove any single arc to break the cycle) +/// assert_eq!(solution.iter().sum::(), 1); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinimumFeedbackArcSet { + /// The directed graph. + graph: DirectedGraph, + /// Weights for each arc. + weights: Vec, +} + +impl MinimumFeedbackArcSet { + /// Create a Minimum Feedback Arc Set problem from a directed graph with given weights. + pub fn new(graph: DirectedGraph, weights: Vec) -> Self { + assert_eq!( + weights.len(), + graph.num_arcs(), + "weights length must match graph num_arcs" + ); + Self { graph, weights } + } + + /// Get a reference to the underlying directed graph. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get a reference to the weights slice. + pub fn weights(&self) -> &[W] { + &self.weights + } + + /// Set arc weights. + pub fn set_weights(&mut self, weights: Vec) { + assert_eq!( + weights.len(), + self.graph.num_arcs(), + "weights length must match graph num_arcs" + ); + self.weights = weights; + } + + /// Check if a configuration is a valid feedback arc set. + /// + /// A configuration is valid if removing the selected arcs makes the graph acyclic. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_valid_fas(&self.graph, config) + } +} + +impl MinimumFeedbackArcSet { + /// Check if the problem has non-unit weights. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Get the number of vertices in the directed graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of arcs in the directed graph. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } +} + +impl Problem for MinimumFeedbackArcSet +where + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "MinimumFeedbackArcSet"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![W] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_arcs()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + if !is_valid_fas(&self.graph, config) { + return SolutionSize::Invalid; + } + let mut total = W::Sum::zero(); + for (i, &selected) in config.iter().enumerate() { + if selected != 0 { + total += self.weights[i].to_sum(); + } + } + SolutionSize::Valid(total) + } +} + +impl OptimizationProblem for MinimumFeedbackArcSet +where + W: WeightElement + crate::variant::VariantParam, +{ + type Value = W::Sum; + + fn direction(&self) -> Direction { + Direction::Minimize + } +} + +/// Check if a configuration forms a valid feedback arc set. +/// +/// config[i] = 1 means arc i is selected for removal. +/// The remaining arcs must form a DAG. +fn is_valid_fas(graph: &DirectedGraph, config: &[usize]) -> bool { + let num_arcs = graph.num_arcs(); + if config.len() != num_arcs { + return false; + } + // kept_arcs[i] = true means arc i is NOT removed (kept in the graph) + let kept_arcs: Vec = config.iter().map(|&x| x == 0).collect(); + graph.is_acyclic_subgraph(&kept_arcs) +} + +crate::declare_variants! { + MinimumFeedbackArcSet => "2^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/minimum_feedback_arc_set.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 59599c5f0..2cb4f9831 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -15,6 +15,7 @@ //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`BicliqueCover`]: Biclique cover on bipartite graphs +//! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs //! - [`RuralPostman`]: Rural Postman (circuit covering required edges) //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) @@ -27,6 +28,7 @@ pub(crate) mod maximum_clique; pub(crate) mod maximum_independent_set; pub(crate) mod maximum_matching; pub(crate) mod minimum_dominating_set; +pub(crate) mod minimum_feedback_arc_set; pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_vertex_cover; pub(crate) mod partition_into_triangles; @@ -44,6 +46,7 @@ pub use maximum_clique::MaximumClique; pub use maximum_independent_set::MaximumIndependentSet; pub use maximum_matching::MaximumMatching; pub use minimum_dominating_set::MinimumDominatingSet; +pub use minimum_feedback_arc_set::MinimumFeedbackArcSet; pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_vertex_cover::MinimumVertexCover; pub use partition_into_triangles::PartitionIntoTriangles; diff --git a/src/models/mod.rs b/src/models/mod.rs index 5b2f80125..af02d8686 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -13,9 +13,9 @@ pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO}; pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, - MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackVertexSet, - MinimumVertexCover, PartitionIntoTriangles, RuralPostman, SpinGlass, SubgraphIsomorphism, - TravelingSalesman, + MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, + MinimumFeedbackVertexSet, MinimumVertexCover, PartitionIntoTriangles, RuralPostman, SpinGlass, + SubgraphIsomorphism, TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/topology/directed_graph.rs b/src/topology/directed_graph.rs index 4f83affbb..3fe0a011b 100644 --- a/src/topology/directed_graph.rs +++ b/src/topology/directed_graph.rs @@ -2,7 +2,7 @@ //! //! This module provides [`DirectedGraph`], a directed graph wrapping petgraph's //! `DiGraph`. It is used for problems that require directed input, such as -//! [`MinimumFeedbackVertexSet`]. +//! [`MinimumFeedbackVertexSet`] and [`MinimumFeedbackArcSet`]. //! //! Unlike [`SimpleGraph`], `DirectedGraph` does **not** implement the [`Graph`] //! trait (which is specific to undirected graphs). Arcs are ordered pairs `(u, v)` @@ -11,6 +11,7 @@ //! [`SimpleGraph`]: crate::topology::SimpleGraph //! [`Graph`]: crate::topology::Graph //! [`MinimumFeedbackVertexSet`]: crate::models::graph::MinimumFeedbackVertexSet +//! [`MinimumFeedbackArcSet`]: crate::models::graph::MinimumFeedbackArcSet use petgraph::algo::toposort; use petgraph::graph::{DiGraph, NodeIndex}; @@ -119,6 +120,21 @@ impl DirectedGraph { .collect() } + /// Returns the out-degree of vertex `v`. + pub fn out_degree(&self, v: usize) -> usize { + self.successors(v).len() + } + + /// Returns the in-degree of vertex `v`. + pub fn in_degree(&self, v: usize) -> usize { + self.predecessors(v).len() + } + + /// Returns true if the graph has no vertices. + pub fn is_empty(&self) -> bool { + self.num_vertices() == 0 + } + /// Returns `true` if the graph is a directed acyclic graph (DAG). /// /// Uses petgraph's topological sort to detect cycles: if a topological @@ -127,6 +143,47 @@ impl DirectedGraph { toposort(&self.inner, None).is_ok() } + /// Check if the subgraph induced by keeping only the given arcs is acyclic (a DAG). + /// + /// `kept_arcs` is a boolean slice of length `num_arcs()`, where `true` means the arc is kept. + /// + /// # Panics + /// + /// Panics if `kept_arcs.len() != self.num_arcs()`. + pub fn is_acyclic_subgraph(&self, kept_arcs: &[bool]) -> bool { + assert_eq!( + kept_arcs.len(), + self.num_arcs(), + "kept_arcs slice length must equal num_arcs" + ); + let n = self.num_vertices(); + let arcs = self.arcs(); + + // Build adjacency list for the subgraph + let mut adj = vec![vec![]; n]; + let mut in_degree = vec![0usize; n]; + for (i, &(u, v)) in arcs.iter().enumerate() { + if kept_arcs[i] { + adj[u].push(v); + in_degree[v] += 1; + } + } + + // Kahn's algorithm (topological sort) + let mut queue: Vec = (0..n).filter(|&v| in_degree[v] == 0).collect(); + let mut visited = 0; + while let Some(u) = queue.pop() { + visited += 1; + for &v in &adj[u] { + in_degree[v] -= 1; + if in_degree[v] == 0 { + queue.push(v); + } + } + } + visited == n + } + /// Returns the induced subgraph on vertices where `keep[v] == true`. /// /// Vertex indices are remapped to be contiguous starting from 0. An arc diff --git a/src/topology/mod.rs b/src/topology/mod.rs index 3d7be152d..8881ca9f7 100644 --- a/src/topology/mod.rs +++ b/src/topology/mod.rs @@ -3,6 +3,7 @@ //! - [`SimpleGraph`]: Standard unweighted graph (default for most problems) //! - [`PlanarGraph`]: Planar graph //! - [`BipartiteGraph`]: Bipartite graph +//! - [`DirectedGraph`]: Directed graph (digraph) //! - [`UnitDiskGraph`]: Vertices with 2D positions, edges based on distance //! - [`KingsSubgraph`]: 8-connected grid graph (King's graph) //! - [`TriangularSubgraph`]: Triangular lattice subgraph diff --git a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs new file mode 100644 index 000000000..09dd95f75 --- /dev/null +++ b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs @@ -0,0 +1,206 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::DirectedGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::Direction; + +#[test] +fn test_minimum_feedback_arc_set_creation() { + // 6 vertices, 9 arcs (example from issue) + let graph = DirectedGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 0), + (1, 3), + (3, 4), + (4, 1), + (2, 5), + (5, 3), + (3, 0), + ], + ); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 9]); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_arcs(), 9); + assert_eq!(problem.dims().len(), 9); + assert!(problem.dims().iter().all(|&d| d == 2)); +} + +#[test] +fn test_minimum_feedback_arc_set_direction() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); + assert_eq!(problem.direction(), Direction::Minimize); +} + +#[test] +fn test_minimum_feedback_arc_set_evaluation_valid() { + // Simple cycle: 0->1->2->0 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); + + // Remove arc 2->0 (index 2) -> breaks the cycle + let config = vec![0, 0, 1]; + let result = problem.evaluate(&config); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 1); + + // Remove arc 0->1 (index 0) -> also breaks the cycle + let config = vec![1, 0, 0]; + let result = problem.evaluate(&config); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 1); + + // Remove all arcs -> valid (trivially acyclic), size 3 + let config = vec![1, 1, 1]; + let result = problem.evaluate(&config); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 3); +} + +#[test] +fn test_minimum_feedback_arc_set_evaluation_invalid() { + // Simple cycle: 0->1->2->0 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); + + // Remove no arcs -> cycle remains -> invalid + let config = vec![0, 0, 0]; + let result = problem.evaluate(&config); + assert!(!result.is_valid()); +} + +#[test] +fn test_minimum_feedback_arc_set_dag() { + // Already a DAG: 0->1->2 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 2]); + + // Remove no arcs -> already acyclic + let config = vec![0, 0]; + let result = problem.evaluate(&config); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 0); +} + +#[test] +fn test_minimum_feedback_arc_set_solver_simple_cycle() { + // Simple cycle: 0->1->2->0 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); + + let solutions = BruteForce::new().find_all_best(&problem); + // Minimum FAS has size 1 (remove any one arc) + for sol in &solutions { + assert_eq!(sol.iter().sum::(), 1); + } + // There are 3 optimal solutions (one for each arc) + assert_eq!(solutions.len(), 3); +} + +#[test] +fn test_minimum_feedback_arc_set_solver_issue_example() { + // Example from issue #213: 6 vertices, 9 arcs + let graph = DirectedGraph::new( + 6, + vec![ + (0, 1), // a0 + (1, 2), // a1 + (2, 0), // a2 + (1, 3), // a3 + (3, 4), // a4 + (4, 1), // a5 + (2, 5), // a6 + (5, 3), // a7 + (3, 0), // a8 + ], + ); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 9]); + + let solution = BruteForce::new().find_best(&problem).unwrap(); + // The optimal FAS has size 2 + let fas_size: usize = solution.iter().sum(); + assert_eq!(fas_size, 2); + + // Verify the solution is valid + assert!(problem.is_valid_solution(&solution)); +} + +#[test] +fn test_minimum_feedback_arc_set_weighted() { + // Cycle: 0->1->2->0 with weights [10, 1, 1] + // Arc 0 (0->1) costs 10, arcs 1,2 cost 1 each + // Optimal: remove arc 1 or arc 2 (cost 1), NOT arc 0 (cost 10) + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![10i32, 1, 1]); + + let solution = BruteForce::new().find_best(&problem).unwrap(); + let result = problem.evaluate(&solution); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 1); // should pick a cheap arc + + // Arc 0 should NOT be selected (too expensive) + assert_eq!(solution[0], 0); +} + +#[test] +fn test_minimum_feedback_arc_set_is_valid_solution() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); + + // Valid: remove one arc from the cycle + assert!(problem.is_valid_solution(&[0, 0, 1])); + // Invalid: keep all arcs (cycle remains) + assert!(!problem.is_valid_solution(&[0, 0, 0])); +} + +#[test] +fn test_minimum_feedback_arc_set_problem_name() { + assert_eq!( + as Problem>::NAME, + "MinimumFeedbackArcSet" + ); +} + +#[test] +fn test_minimum_feedback_arc_set_serialization() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: MinimumFeedbackArcSet = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.num_vertices(), 3); + assert_eq!(deserialized.num_arcs(), 3); +} + +#[test] +fn test_minimum_feedback_arc_set_two_disjoint_cycles() { + // Two disjoint cycles: 0->1->0 and 2->3->2 + let graph = DirectedGraph::new(4, vec![(0, 1), (1, 0), (2, 3), (3, 2)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 4]); + + let solution = BruteForce::new().find_best(&problem).unwrap(); + // Need to remove at least one arc from each cycle -> size 2 + assert_eq!(solution.iter().sum::(), 2); +} + +#[test] +fn test_minimum_feedback_arc_set_size_getters() { + let graph = DirectedGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]); + let problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 5]); + assert_eq!(problem.num_vertices(), 5); + assert_eq!(problem.num_arcs(), 5); +} + +#[test] +fn test_minimum_feedback_arc_set_accessors() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let mut problem = MinimumFeedbackArcSet::new(graph, vec![1i32; 3]); + + assert!(problem.is_weighted()); // i32 type → true + assert_eq!(problem.weights(), &[1, 1, 1]); + + problem.set_weights(vec![2, 3, 4]); + assert_eq!(problem.weights(), &[2, 3, 4]); +} diff --git a/src/unit_tests/topology/directed_graph.rs b/src/unit_tests/topology/directed_graph.rs index 3b35cdf40..859cf6e4e 100644 --- a/src/unit_tests/topology/directed_graph.rs +++ b/src/unit_tests/topology/directed_graph.rs @@ -12,6 +12,10 @@ fn test_directed_graph_empty() { let g = DirectedGraph::empty(5); assert_eq!(g.num_vertices(), 5); assert_eq!(g.num_arcs(), 0); + assert!(!g.is_empty()); + + let empty = DirectedGraph::new(0, vec![]); + assert!(empty.is_empty()); } #[test] @@ -55,6 +59,17 @@ fn test_directed_graph_predecessors() { assert_eq!(g.predecessors(1), vec![0]); } +#[test] +fn test_directed_graph_degrees() { + let graph = DirectedGraph::new(3, vec![(0, 1), (0, 2), (1, 2)]); + assert_eq!(graph.out_degree(0), 2); + assert_eq!(graph.out_degree(1), 1); + assert_eq!(graph.out_degree(2), 0); + assert_eq!(graph.in_degree(0), 0); + assert_eq!(graph.in_degree(1), 1); + assert_eq!(graph.in_degree(2), 2); +} + #[test] fn test_directed_graph_is_dag_true() { // Simple path: 0 → 1 → 2 @@ -82,6 +97,20 @@ fn test_directed_graph_is_dag_self_loop() { assert!(!g.is_dag()); } +#[test] +fn test_directed_graph_is_acyclic_subgraph() { + // Cycle: 0->1->2->0 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + // Keep all arcs -> has cycle + assert!(!graph.is_acyclic_subgraph(&[true, true, true])); + // Remove arc 2->0 -> acyclic + assert!(graph.is_acyclic_subgraph(&[true, true, false])); + // Remove arc 0->1 -> acyclic + assert!(graph.is_acyclic_subgraph(&[false, true, true])); + // Keep no arcs -> trivially acyclic + assert!(graph.is_acyclic_subgraph(&[false, false, false])); +} + #[test] fn test_directed_graph_induced_subgraph_basic() { // 0 → 1 → 2 → 0 (cycle), keep vertices 0 and 1 (drop 2) diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 7eef06601..a90872ca8 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -3,7 +3,7 @@ use crate::models::formula::*; use crate::models::graph::*; use crate::models::misc::*; use crate::models::set::*; -use crate::topology::{BipartiteGraph, SimpleGraph}; +use crate::topology::{BipartiteGraph, DirectedGraph, SimpleGraph}; use crate::traits::Problem; use crate::variant::K3; @@ -83,6 +83,13 @@ fn test_all_problems_implement_trait_correctly() { BooleanExpr::constant(true), )]); check_problem_trait(&CircuitSAT::new(circuit), "CircuitSAT"); + check_problem_trait( + &MinimumFeedbackArcSet::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1i32; 3], + ), + "MinimumFeedbackArcSet", + ); } #[test] @@ -124,6 +131,14 @@ fn test_direction() { BicliqueCover::new(BipartiteGraph::new(2, 2, vec![(0, 0)]), 1).direction(), Direction::Minimize ); + assert_eq!( + MinimumFeedbackArcSet::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1i32; 3] + ) + .direction(), + Direction::Minimize + ); // Maximization problems assert_eq!(