|
| 1 | +//! Fast cycle-breaking heuristics for lax open hypergraphs. |
| 2 | +
|
| 3 | +use super::{NodeId, OpenHypergraph}; |
| 4 | +use std::cmp::Reverse; |
| 5 | +use std::collections::{BinaryHeap, VecDeque}; |
| 6 | + |
| 7 | +/// Choose nodes whose spiderization is guaranteed to break every directed |
| 8 | +/// cycle. |
| 9 | +/// |
| 10 | +/// This computes a greedy directed feedback vertex set on the bipartite |
| 11 | +/// incidence graph |
| 12 | +/// |
| 13 | +/// ```text |
| 14 | +/// node -> operation -> node. |
| 15 | +/// ``` |
| 16 | +/// |
| 17 | +/// Basic idea: |
| 18 | +/// |
| 19 | +/// - Construct a bipartite node-operation incidence graph. |
| 20 | +/// - Vertices with indegree zero or outdegree zero cannot participate in a cycle. |
| 21 | +/// - Repeatedly peel those vertices until no more acyclic fringe can be removed. |
| 22 | +/// - Select the remaining wire node with the largest `indegree * outdegree` |
| 23 | +/// score, remove it, and repeat. |
| 24 | +/// |
| 25 | +/// Selecting high-degree nodes in this way tends to break several cycles at once. |
| 26 | +/// |
| 27 | +/// The result is deterministic and is guaranteed to make |
| 28 | +/// `f.clone().spiderize_nodes(&result)` acyclic. It is a heuristic: the |
| 29 | +/// returned set need not have minimum size. |
| 30 | +/// |
| 31 | +/// Returns [`None`] if `f` has pending node identifications. Quotienting can |
| 32 | +/// create cycles, so cycle breaking must operate on the semantic, quotiented |
| 33 | +/// graph. When successful, the returned IDs refer directly to `f`. |
| 34 | +#[must_use] |
| 35 | +pub fn cycle_breaking_nodes<O, A>(f: &OpenHypergraph<O, A>) -> Option<Vec<NodeId>> { |
| 36 | + if !f.hypergraph.is_strict() { |
| 37 | + return None; |
| 38 | + } |
| 39 | + |
| 40 | + assert_eq!( |
| 41 | + f.hypergraph.edges.len(), |
| 42 | + f.hypergraph.adjacency.len(), |
| 43 | + "malformed hypergraph: edges and adjacency lengths differ" |
| 44 | + ); |
| 45 | + |
| 46 | + let node_count = f.hypergraph.nodes.len(); |
| 47 | + |
| 48 | + // Allocate a bipartite incidence graph. |
| 49 | + // Nodes occupy 0..node_count, followed by one vertex for each operation. |
| 50 | + // Keeping operation vertices avoids expanding an m -> n hyperedge into m*n arcs. |
| 51 | + let vertex_count = node_count + f.hypergraph.adjacency.len(); |
| 52 | + let mut outgoing = vec![Vec::new(); vertex_count]; |
| 53 | + let mut incoming = vec![Vec::new(); vertex_count]; |
| 54 | + |
| 55 | + // Populate the incidence graph. |
| 56 | + // Preserve the hypergraph's direction: every source node points to its |
| 57 | + // operation, and that operation points to each target node. |
| 58 | + for (operation, adjacency) in f.hypergraph.adjacency.iter().enumerate() { |
| 59 | + let operation = node_count + operation; |
| 60 | + |
| 61 | + for source in &adjacency.sources { |
| 62 | + add_arc(source.0, operation, &mut outgoing, &mut incoming); |
| 63 | + } |
| 64 | + for target in &adjacency.targets { |
| 65 | + add_arc(operation, target.0, &mut outgoing, &mut incoming); |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + // Only node vertices are selectable; operation vertices may be peeled but |
| 70 | + // never returned. |
| 71 | + let selected = greedy_feedback_nodes(node_count, &outgoing, &incoming); |
| 72 | + |
| 73 | + Some(selected.into_iter().map(NodeId).collect()) |
| 74 | +} |
| 75 | + |
| 76 | +/// Add one directed arc to both forward and reverse adjacency lists. |
| 77 | +/// |
| 78 | +/// Keeping both directions lets vertex removal update neighboring in- and |
| 79 | +/// out-degrees without searching the whole graph. |
| 80 | +fn add_arc(source: usize, target: usize, outgoing: &mut [Vec<usize>], incoming: &mut [Vec<usize>]) { |
| 81 | + outgoing[source].push(target); |
| 82 | + incoming[target].push(source); |
| 83 | +} |
| 84 | + |
| 85 | +//////////////////////////////////////////////////////////////////////////////// |
| 86 | +// Cycle breaking logic |
| 87 | + |
| 88 | +/// Greedily select wire vertices that hit every directed cycle. |
| 89 | +/// |
| 90 | +/// Vertices `0..selectable_count` are wire nodes and may be selected; the |
| 91 | +/// remaining vertices are operations. Sources and sinks are peeled, then the |
| 92 | +/// highest-scoring wire vertex is selected whenever cyclic structure remains. |
| 93 | +/// |
| 94 | +/// Runs in roughly `O((vertices + arcs) log(selectable vertices))` time. |
| 95 | +fn greedy_feedback_nodes( |
| 96 | + selectable_count: usize, |
| 97 | + outgoing: &[Vec<usize>], |
| 98 | + incoming: &[Vec<usize>], |
| 99 | +) -> Vec<usize> { |
| 100 | + debug_assert_eq!(outgoing.len(), incoming.len()); |
| 101 | + |
| 102 | + // Degrees and `active` describe the current residual graph. Every vertex |
| 103 | + // is removed exactly once, either freely or as a selected cycle breaker. |
| 104 | + let vertex_count = outgoing.len(); |
| 105 | + let mut active = vec![true; vertex_count]; |
| 106 | + let mut active_count = vertex_count; |
| 107 | + let mut indegree: Vec<usize> = incoming.iter().map(Vec::len).collect(); |
| 108 | + let mut outdegree: Vec<usize> = outgoing.iter().map(Vec::len).collect(); |
| 109 | + let mut peel = VecDeque::new(); |
| 110 | + let mut candidates = BinaryHeap::new(); |
| 111 | + let mut selected = Vec::new(); |
| 112 | + |
| 113 | + // Seed both worklists: immediately peelable vertices go in the FIFO queue, |
| 114 | + // while selectable vertices with two-sided connectivity go in the heap. |
| 115 | + for vertex in 0..vertex_count { |
| 116 | + if indegree[vertex] == 0 || outdegree[vertex] == 0 { |
| 117 | + peel.push_back(vertex); |
| 118 | + } |
| 119 | + push_candidate( |
| 120 | + vertex, |
| 121 | + selectable_count, |
| 122 | + &active, |
| 123 | + &indegree, |
| 124 | + &outdegree, |
| 125 | + &mut candidates, |
| 126 | + ); |
| 127 | + } |
| 128 | + |
| 129 | + while active_count > 0 { |
| 130 | + // Exhaust all consequences of previous removals before choosing |
| 131 | + // another feedback node. This prevents selecting vertices already |
| 132 | + // proven not to participate in the residual cycles. |
| 133 | + while let Some(vertex) = peel.pop_front() { |
| 134 | + if !active[vertex] || (indegree[vertex] > 0 && outdegree[vertex] > 0) { |
| 135 | + continue; |
| 136 | + } |
| 137 | + remove_vertex( |
| 138 | + vertex, |
| 139 | + selectable_count, |
| 140 | + outgoing, |
| 141 | + incoming, |
| 142 | + &mut active, |
| 143 | + &mut indegree, |
| 144 | + &mut outdegree, |
| 145 | + &mut peel, |
| 146 | + &mut candidates, |
| 147 | + ); |
| 148 | + active_count -= 1; |
| 149 | + } |
| 150 | + |
| 151 | + if active_count == 0 { |
| 152 | + break; |
| 153 | + } |
| 154 | + |
| 155 | + // Peeling got stuck, so the residual graph is cyclic. Pop until the |
| 156 | + // degree snapshot agrees with current state, skipping lazy stale |
| 157 | + // entries, then choose the best-scoring node. |
| 158 | + let vertex = loop { |
| 159 | + let (_, Reverse(vertex), candidate_indegree, candidate_outdegree) = candidates |
| 160 | + .pop() |
| 161 | + .expect("cyclic residual incidence graph must contain a selectable node"); |
| 162 | + if active[vertex] |
| 163 | + && indegree[vertex] == candidate_indegree |
| 164 | + && outdegree[vertex] == candidate_outdegree |
| 165 | + { |
| 166 | + break vertex; |
| 167 | + } |
| 168 | + }; |
| 169 | + |
| 170 | + // This is the only non-free removal: record it for spiderization. |
| 171 | + // Removing it may expose a large acyclic fringe for the next peel. |
| 172 | + selected.push(vertex); |
| 173 | + remove_vertex( |
| 174 | + vertex, |
| 175 | + selectable_count, |
| 176 | + outgoing, |
| 177 | + incoming, |
| 178 | + &mut active, |
| 179 | + &mut indegree, |
| 180 | + &mut outdegree, |
| 181 | + &mut peel, |
| 182 | + &mut candidates, |
| 183 | + ); |
| 184 | + active_count -= 1; |
| 185 | + } |
| 186 | + |
| 187 | + // Heap choice order is an implementation detail; node order is a more |
| 188 | + // stable and convenient public result. |
| 189 | + selected.sort_unstable(); |
| 190 | + selected |
| 191 | +} |
| 192 | + |
| 193 | +/// Estimate how much cyclic connectivity removing a node will disrupt. |
| 194 | +/// |
| 195 | +/// A node with many incoming and outgoing arcs joins many possible paths. The |
| 196 | +/// saturating product avoids overflow for unusually large incidence graphs. |
| 197 | +fn score(indegree: usize, outdegree: usize) -> usize { |
| 198 | + indegree.saturating_mul(outdegree) |
| 199 | +} |
| 200 | + |
| 201 | +/// A lazily validated heap entry. |
| 202 | +/// |
| 203 | +/// Entries contain `(score, node, indegree, outdegree)`. [`Reverse`] makes the |
| 204 | +/// lower node index win deterministic ties in the max-heap. The degree |
| 205 | +/// snapshots let us recognize entries made stale by later removals. |
| 206 | +type Candidate = (usize, Reverse<usize>, usize, usize); |
| 207 | + |
| 208 | +/// Add a selectable vertex's current state to the candidate heap. |
| 209 | +/// |
| 210 | +/// The heap is deliberately lazy: degree changes push new entries rather than |
| 211 | +/// locating and updating old ones. Stale entries are discarded when popped. |
| 212 | +fn push_candidate( |
| 213 | + node: usize, |
| 214 | + selectable_count: usize, |
| 215 | + active: &[bool], |
| 216 | + indegree: &[usize], |
| 217 | + outdegree: &[usize], |
| 218 | + candidates: &mut BinaryHeap<Candidate>, |
| 219 | +) { |
| 220 | + if node < selectable_count && active[node] && indegree[node] > 0 && outdegree[node] > 0 { |
| 221 | + candidates.push(( |
| 222 | + score(indegree[node], outdegree[node]), |
| 223 | + Reverse(node), |
| 224 | + indegree[node], |
| 225 | + outdegree[node], |
| 226 | + )); |
| 227 | + } |
| 228 | +} |
| 229 | + |
| 230 | +/// Remove a vertex from the active graph and update its neighbors. |
| 231 | +/// |
| 232 | +/// Removing outgoing arcs lowers target in-degrees; removing incoming arcs |
| 233 | +/// lowers source out-degrees. Neighbors that become sources or sinks enter the |
| 234 | +/// free peeling queue, while still-cyclic node vertices receive refreshed heap |
| 235 | +/// entries. |
| 236 | +#[allow(clippy::too_many_arguments)] |
| 237 | +fn remove_vertex( |
| 238 | + vertex: usize, |
| 239 | + selectable_count: usize, |
| 240 | + outgoing: &[Vec<usize>], |
| 241 | + incoming: &[Vec<usize>], |
| 242 | + active: &mut [bool], |
| 243 | + indegree: &mut [usize], |
| 244 | + outdegree: &mut [usize], |
| 245 | + peel: &mut VecDeque<usize>, |
| 246 | + candidates: &mut BinaryHeap<Candidate>, |
| 247 | +) { |
| 248 | + // Mark first so a self-arc, if one is ever supplied, cannot update the |
| 249 | + // removed vertex's own degree. |
| 250 | + active[vertex] = false; |
| 251 | + |
| 252 | + // Delete vertex -> target arcs. |
| 253 | + for &target in &outgoing[vertex] { |
| 254 | + if active[target] { |
| 255 | + indegree[target] -= 1; |
| 256 | + if indegree[target] == 0 || outdegree[target] == 0 { |
| 257 | + peel.push_back(target); |
| 258 | + } |
| 259 | + push_candidate( |
| 260 | + target, |
| 261 | + selectable_count, |
| 262 | + active, |
| 263 | + indegree, |
| 264 | + outdegree, |
| 265 | + candidates, |
| 266 | + ); |
| 267 | + } |
| 268 | + } |
| 269 | + |
| 270 | + // Delete source -> vertex arcs. |
| 271 | + for &source in &incoming[vertex] { |
| 272 | + if active[source] { |
| 273 | + outdegree[source] -= 1; |
| 274 | + if indegree[source] == 0 || outdegree[source] == 0 { |
| 275 | + peel.push_back(source); |
| 276 | + } |
| 277 | + push_candidate( |
| 278 | + source, |
| 279 | + selectable_count, |
| 280 | + active, |
| 281 | + indegree, |
| 282 | + outdegree, |
| 283 | + candidates, |
| 284 | + ); |
| 285 | + } |
| 286 | + } |
| 287 | +} |
0 commit comments