Skip to main content

pounce_presolve/
matching.rs

1//! Hopcroft-Karp bipartite matching on an [`crate::incidence::EqualityIncidence`].
2//!
3//! PR 2 of the auxiliary-presolve port (issue #53). ripopt anchor:
4//! `src/auxiliary_preprocessing.rs:2280-2318`.
5//!
6//! Hopcroft-Karp finds a maximum bipartite matching in
7//! `O(E · sqrt(V))` time by alternating BFS layering with DFS
8//! augmentation along shortest augmenting paths.
9
10use std::collections::VecDeque;
11
12use crate::incidence::EqualityIncidence;
13
14const NIL: usize = usize::MAX;
15const INF: usize = usize::MAX;
16
17/// Maximum-cardinality bipartite matching between equality rows
18/// (left) and variables (right).
19#[derive(Debug, Clone, Default)]
20pub struct BipartiteMatching {
21    /// `row_to_var[k] = Some(j)` when equality row `k` is matched to
22    /// variable `j`, else `None`. Length = `n_eq_rows`.
23    pub row_to_var: Vec<Option<usize>>,
24    /// Inverse mapping; length = `n_vars`.
25    pub var_to_row: Vec<Option<usize>>,
26    /// Cardinality of the matching.
27    pub size: usize,
28}
29
30/// Run Hopcroft-Karp on `inc` and return the maximum matching.
31///
32/// # Example
33///
34/// ```
35/// use pounce_presolve::incidence::{EqualityIncidence, ProbeView};
36/// use pounce_presolve::matching::hopcroft_karp;
37///
38/// // 2 equality rows × 2 vars, each row touching one distinct var.
39/// let p = ProbeView {
40///     n_vars: 2,
41///     m_rows: 2,
42///     jac_irow: &[0, 1],
43///     jac_jcol: &[0, 1],
44///     jac_values: None,
45///     g_l: &[0.0, 0.0],
46///     g_u: &[0.0, 0.0],
47///     linearity: None,
48///     one_based: false,
49///     eq_tol: 1e-12,
50///     excluded_vars: None,
51///     excluded_rows: None,
52/// };
53/// let inc = EqualityIncidence::from_probe(&p);
54/// let m = hopcroft_karp(&inc);
55/// assert_eq!(m.size, 2);
56/// ```
57pub fn hopcroft_karp(inc: &EqualityIncidence) -> BipartiteMatching {
58    let n_rows = inc.n_eq_rows();
59    let n_vars = inc.n_vars;
60
61    // `pair_u[r]` is the var matched to row r, NIL when unmatched.
62    let mut pair_u: Vec<usize> = vec![NIL; n_rows];
63    let mut pair_v: Vec<usize> = vec![NIL; n_vars];
64    // BFS distance from row r, when participating in the layered graph.
65    let mut dist: Vec<usize> = vec![INF; n_rows];
66
67    let mut size = 0;
68    // Each successful BFS round augments at least one path, so the
69    // outer loop terminates in O(min(n_rows, n_vars)) rounds. Cap
70    // defensively at 2x to catch any subtle bug as a panic in tests
71    // rather than as a runaway loop in production.
72    let max_rounds = (n_rows.min(n_vars) + 1) * 2;
73    let mut round = 0;
74    while bfs(inc, &pair_u, &pair_v, &mut dist) {
75        let mut augmented = false;
76        for r in 0..n_rows {
77            if pair_u[r] == NIL && dfs(inc, r, &mut pair_u, &mut pair_v, &mut dist) {
78                size += 1;
79                augmented = true;
80            }
81        }
82        if !augmented {
83            // BFS claimed a path exists but DFS found none — that
84            // would mean an invariant break. Bail out instead of
85            // looping forever.
86            break;
87        }
88        round += 1;
89        debug_assert!(round <= max_rounds, "Hopcroft-Karp round cap exceeded");
90    }
91
92    let row_to_var = pair_u
93        .iter()
94        .map(|&v| if v == NIL { None } else { Some(v) })
95        .collect();
96    let var_to_row = pair_v
97        .iter()
98        .map(|&r| if r == NIL { None } else { Some(r) })
99        .collect();
100
101    BipartiteMatching {
102        row_to_var,
103        var_to_row,
104        size,
105    }
106}
107
108/// BFS layering with the `dist[NIL]` sentinel. Records the
109/// shortest distance to *any* unmatched column and prunes BFS
110/// expansion past that layer, recovering the textbook O(√V)
111/// phase-count bound (PR #60 review nit).
112fn bfs(inc: &EqualityIncidence, pair_u: &[usize], pair_v: &[usize], dist: &mut [usize]) -> bool {
113    let mut queue: VecDeque<usize> = VecDeque::new();
114    for r in 0..inc.n_eq_rows() {
115        if pair_u[r] == NIL {
116            dist[r] = 0;
117            queue.push_back(r);
118        } else {
119            dist[r] = INF;
120        }
121    }
122    // `dist_nil` is the distance to the nearest unmatched column,
123    // used both as the augmenting-path-found signal and as a BFS
124    // pruning threshold.
125    let mut dist_nil = INF;
126    while let Some(r) = queue.pop_front() {
127        if dist[r] >= dist_nil {
128            continue;
129        }
130        for &v in inc.neighbors(r) {
131            let next = pair_v[v];
132            if next == NIL {
133                // Reaching an unmatched column means an augmenting
134                // path ends here; pin `dist_nil` to the shortest
135                // such layer (and skip exploring further past it).
136                if dist_nil == INF {
137                    dist_nil = dist[r] + 1;
138                }
139            } else if dist[next] == INF {
140                dist[next] = dist[r] + 1;
141                queue.push_back(next);
142            }
143        }
144    }
145    dist_nil != INF
146}
147
148/// DFS along the layered graph, flipping any augmenting path it
149/// finds. Returns `true` iff `r` participated in an augmentation.
150fn dfs(
151    inc: &EqualityIncidence,
152    r: usize,
153    pair_u: &mut [usize],
154    pair_v: &mut [usize],
155    dist: &mut [usize],
156) -> bool {
157    for &v in inc.neighbors(r) {
158        let next = pair_v[v];
159        let ok = if next == NIL {
160            true
161        } else if dist[next] == dist[r] + 1 {
162            dfs(inc, next, pair_u, pair_v, dist)
163        } else {
164            false
165        };
166        if ok {
167            pair_u[r] = v;
168            pair_v[v] = r;
169            return true;
170        }
171    }
172    dist[r] = INF;
173    false
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::incidence::ProbeView;
180    use pounce_common::types::{Index, Number};
181
182    /// Build an `EqualityIncidence` directly from an edge list
183    /// (skipping `ProbeView` so tests don't need to fabricate
184    /// `(g_l, g_u)` slices). Every row is treated as an equality.
185    fn eq_inc(n_vars: usize, n_rows: usize, edges: &[(usize, usize)]) -> EqualityIncidence {
186        let mut per_row: Vec<Vec<usize>> = vec![Vec::new(); n_rows];
187        for &(r, v) in edges {
188            per_row[r].push(v);
189        }
190        let mut adj_ptr = Vec::with_capacity(n_rows + 1);
191        let mut vars = Vec::new();
192        adj_ptr.push(0);
193        for row in per_row.iter_mut() {
194            row.sort_unstable();
195            row.dedup();
196            vars.extend_from_slice(row);
197            adj_ptr.push(vars.len());
198        }
199        EqualityIncidence {
200            n_vars,
201            eq_row_inner_idx: (0..n_rows).collect(),
202            adj_ptr,
203            vars,
204        }
205    }
206
207    #[test]
208    fn square_full_match_3x3() {
209        // Diagonal-ish graph: row k touches col k. Trivial matching
210        // of size 3.
211        let inc = eq_inc(3, 3, &[(0, 0), (1, 1), (2, 2)]);
212        let m = hopcroft_karp(&inc);
213        assert_eq!(m.size, 3);
214        assert_eq!(m.row_to_var, vec![Some(0), Some(1), Some(2)]);
215    }
216
217    #[test]
218    fn under_determined_2x3() {
219        // 2 rows, 3 vars. Row 0 → {0, 1}; row 1 → {1, 2}. Max match = 2.
220        let inc = eq_inc(3, 2, &[(0, 0), (0, 1), (1, 1), (1, 2)]);
221        let m = hopcroft_karp(&inc);
222        assert_eq!(m.size, 2);
223        assert!(m.row_to_var.iter().all(|v| v.is_some()));
224    }
225
226    #[test]
227    fn over_determined_3x2() {
228        // 3 rows, 2 vars. Rows 0 and 2 both want var 0 / 1.
229        let inc = eq_inc(2, 3, &[(0, 0), (1, 1), (2, 0), (2, 1)]);
230        let m = hopcroft_karp(&inc);
231        assert_eq!(m.size, 2);
232        assert_eq!(m.row_to_var.iter().filter(|v| v.is_some()).count(), 2);
233    }
234
235    #[test]
236    fn disconnected_components() {
237        // Two disjoint K1,1s: row 0 ↔ var 0, row 1 ↔ var 1.
238        let inc = eq_inc(2, 2, &[(0, 0), (1, 1)]);
239        let m = hopcroft_karp(&inc);
240        assert_eq!(m.size, 2);
241        assert_eq!(m.row_to_var, vec![Some(0), Some(1)]);
242    }
243
244    #[test]
245    fn augmenting_path_required() {
246        // Two rows, two vars; edges (0,0), (0,1), (1,0). Greedy
247        // pairing row0→var0 leaves row1 stuck; Hopcroft-Karp must
248        // augment to size 2.
249        let inc = eq_inc(2, 2, &[(0, 0), (0, 1), (1, 0)]);
250        let m = hopcroft_karp(&inc);
251        assert_eq!(m.size, 2);
252    }
253
254    #[test]
255    fn empty_graph_yields_empty_matching() {
256        let inc = eq_inc(0, 0, &[]);
257        let m = hopcroft_karp(&inc);
258        assert_eq!(m.size, 0);
259        assert!(m.row_to_var.is_empty());
260        assert!(m.var_to_row.is_empty());
261    }
262
263    #[test]
264    fn matches_built_via_probeview_too() {
265        // Smoke test: building via ProbeView produces the same
266        // matching as building the incidence directly.
267        let irow: [Index; 3] = [0, 0, 1];
268        let jcol: [Index; 3] = [0, 1, 0];
269        let g: [Number; 2] = [0.0, 0.0];
270        let p = ProbeView {
271            n_vars: 2,
272            m_rows: 2,
273            jac_irow: &irow,
274            jac_jcol: &jcol,
275            jac_values: None,
276            g_l: &g,
277            g_u: &g,
278            linearity: None,
279            one_based: false,
280            eq_tol: 1e-12,
281            excluded_vars: None,
282            excluded_rows: None,
283        };
284        let inc = EqualityIncidence::from_probe(&p);
285        let m = hopcroft_karp(&inc);
286        assert_eq!(m.size, 2);
287    }
288
289    // Brute-force max matching by enumerating edge subsets. Only used
290    // in `konigs_theorem_random_check` where graphs are tiny.
291    // Reuses scratch buffers to keep debug-mode allocations down.
292    fn brute_force_max_matching(edges: &[(usize, usize)], n_rows: usize, n_vars: usize) -> usize {
293        let e = edges.len();
294        let mut row_used = vec![false; n_rows];
295        let mut var_used = vec![false; n_vars];
296        let mut best = 0;
297        for mask in 0u32..(1u32 << e) {
298            row_used.iter_mut().for_each(|v| *v = false);
299            var_used.iter_mut().for_each(|v| *v = false);
300            let mut count = 0;
301            let mut valid = true;
302            for k in 0..e {
303                if (mask >> k) & 1 == 1 {
304                    let (r, v) = edges[k];
305                    if row_used[r] || var_used[v] {
306                        valid = false;
307                        break;
308                    }
309                    row_used[r] = true;
310                    var_used[v] = true;
311                    count += 1;
312                }
313            }
314            if valid && count > best {
315                best = count;
316            }
317        }
318        best
319    }
320
321    /// Cross-check Hopcroft-Karp against brute force on small random
322    /// bipartite graphs. König's theorem guarantees both algorithms
323    /// agree on the cardinality (it's the size of a minimum vertex
324    /// cover); discrepancies mean Hopcroft-Karp is wrong.
325    ///
326    /// Graphs capped at 4×4 with ≤ 8 edges so the 2^e brute-force
327    /// enumeration stays fast in debug builds.
328    #[test]
329    fn konigs_theorem_random_check() {
330        // Deterministic LCG to keep the test reproducible without
331        // pulling in `rand` as a dev-dep. The low bits of a linear
332        // congruential generator with odd multiplier and odd
333        // increment cycle predictably (parity alternates every call),
334        // so we always sample from the high 32 bits before taking a
335        // modulus.
336        let mut state: u64 = 0xdead_beef_cafe_f00d;
337        let mut next = || -> u64 {
338            state = state
339                .wrapping_mul(6364136223846793005)
340                .wrapping_add(1442695040888963407);
341            state >> 32
342        };
343
344        for _ in 0..20 {
345            let n_rows = 1 + (next() % 4) as usize; // 1..=4
346            let n_vars = 1 + (next() % 4) as usize; // 1..=4
347            let max_edges = (n_rows * n_vars).min(8); // 2^8 = 256
348            let n_edges = (next() % (max_edges as u64 + 1)) as usize;
349
350            // Bail out if we'd need too many distinct pairs from a
351            // small space — defensive, the cap above already
352            // guarantees `n_edges <= n_rows * n_vars`.
353            let mut edges_set = std::collections::BTreeSet::<(usize, usize)>::new();
354            let mut draws = 0usize;
355            while edges_set.len() < n_edges {
356                let r = (next() % n_rows as u64) as usize;
357                let v = (next() % n_vars as u64) as usize;
358                edges_set.insert((r, v));
359                draws += 1;
360                assert!(draws < 10_000, "edge draw loop is not making progress");
361            }
362            let edges: Vec<(usize, usize)> = edges_set.into_iter().collect();
363
364            let inc = eq_inc(n_vars, n_rows, &edges);
365            let hk = hopcroft_karp(&inc).size;
366            let bf = brute_force_max_matching(&edges, n_rows, n_vars);
367            assert_eq!(
368                hk, bf,
369                "Hopcroft-Karp disagrees with brute force on {edges:?} ({n_rows}x{n_vars})"
370            );
371        }
372    }
373}