Skip to main content

scirs2_optimize/combinatorial/
assignment.rs

1//! Assignment problem solvers.
2//!
3//! Provides the Hungarian algorithm (Kuhn-Munkres) for minimum-cost square
4//! or rectangular assignment, and a general minimum-cost bipartite matching.
5
6use crate::error::OptimizeError;
7use scirs2_core::ndarray::Array2;
8
9/// Result type for assignment operations.
10pub type AssignmentResult<T> = Result<T, OptimizeError>;
11
12// ── Hungarian algorithm (Kuhn-Munkres) ────────────────────────────────────────
13
14/// Solve the minimum-cost assignment problem using the Hungarian algorithm.
15///
16/// Given an `n × m` cost matrix, finds an assignment of n workers to jobs
17/// (one each) that minimises total cost.  For non-square matrices the smaller
18/// dimension is padded with zeros.
19///
20/// Returns `(assignment, total_cost)` where `assignment[i] = j` means row `i`
21/// is assigned to column `j`.
22///
23/// Time complexity: O(n³) in the square case.
24///
25/// # Errors
26/// Returns an error if the cost matrix is empty.
27pub fn hungarian_algorithm(cost: &Array2<f64>) -> AssignmentResult<(Vec<usize>, f64)> {
28    let rows = cost.nrows();
29    let cols = cost.ncols();
30    if rows == 0 || cols == 0 {
31        return Err(OptimizeError::InvalidInput(
32            "Cost matrix must be non-empty".to_string(),
33        ));
34    }
35
36    // Pad to square: n = max(rows, cols)
37    let n = rows.max(cols);
38    let mut c = vec![vec![0.0f64; n]; n];
39    for i in 0..rows {
40        for j in 0..cols {
41            c[i][j] = cost[[i, j]];
42        }
43    }
44
45    // ── Phase 1: subtract row minima ─────────────────────────────────────────
46    for row in c.iter_mut() {
47        let min_val = row.iter().cloned().fold(f64::INFINITY, f64::min);
48        if min_val.is_finite() {
49            for x in row.iter_mut() {
50                *x -= min_val;
51            }
52        }
53    }
54
55    // ── Phase 2: subtract column minima ──────────────────────────────────────
56    for j in 0..n {
57        let min_val = (0..n).map(|i| c[i][j]).fold(f64::INFINITY, f64::min);
58        if min_val.is_finite() {
59            for i in 0..n {
60                c[i][j] -= min_val;
61            }
62        }
63    }
64
65    // ── Main loop: cover zeros with minimum lines, then augment ───────────────
66    let mut row_assign = vec![usize::MAX; n]; // row_assign[i] = assigned column
67    let mut col_assign = vec![usize::MAX; n]; // col_assign[j] = assigned row
68
69    loop {
70        // Greedily assign zeros
71        row_assign = vec![usize::MAX; n];
72        col_assign = vec![usize::MAX; n];
73        greedy_assign_zeros(&c, &mut row_assign, &mut col_assign, n);
74
75        // Count assigned rows
76        let assigned = row_assign.iter().filter(|&&x| x != usize::MAX).count();
77        if assigned == n {
78            break;
79        }
80
81        // Find minimum line cover of zeros
82        let (row_covered, col_covered) = min_line_cover(&c, &row_assign, &col_assign, n);
83
84        // Find uncovered minimum
85        let mut min_uncovered = f64::INFINITY;
86        for i in 0..n {
87            if row_covered[i] {
88                continue;
89            }
90            for j in 0..n {
91                if !col_covered[j] && c[i][j] < min_uncovered {
92                    min_uncovered = c[i][j];
93                }
94            }
95        }
96
97        if !min_uncovered.is_finite() || min_uncovered <= 0.0 {
98            break; // degenerate case
99        }
100
101        // Subtract from uncovered, add to doubly-covered
102        for i in 0..n {
103            for j in 0..n {
104                if !row_covered[i] && !col_covered[j] {
105                    c[i][j] -= min_uncovered;
106                } else if row_covered[i] && col_covered[j] {
107                    c[i][j] += min_uncovered;
108                }
109            }
110        }
111    }
112
113    // Use augmenting path matching to find maximum matching of zeros
114    row_assign = vec![usize::MAX; n];
115    col_assign = vec![usize::MAX; n];
116    for i in 0..n {
117        let mut visited_cols = vec![false; n];
118        augment_assignment(
119            i,
120            &c,
121            &mut row_assign,
122            &mut col_assign,
123            &mut visited_cols,
124            n,
125        );
126    }
127
128    // Compute total cost using original cost matrix.
129    // Padded-column assignments (j >= cols) are preserved as-is so callers can
130    // distinguish real vs. dummy assignments.
131    let mut total_cost = 0.0;
132    let mut assignment = vec![0usize; rows];
133    for i in 0..rows {
134        let j = row_assign[i];
135        assignment[i] = j; // preserve padded column index (j >= cols means dummy)
136        if j < cols {
137            total_cost += cost[[i, j]];
138        }
139    }
140
141    Ok((assignment, total_cost))
142}
143
144/// Greedy zero assignment: assign zeros to maximise the number of assigned rows.
145fn greedy_assign_zeros(
146    c: &[Vec<f64>],
147    row_assign: &mut Vec<usize>,
148    col_assign: &mut Vec<usize>,
149    n: usize,
150) {
151    // First pass: assign rows with only one zero
152    let mut changed = true;
153    while changed {
154        changed = false;
155        for i in 0..n {
156            if row_assign[i] != usize::MAX {
157                continue;
158            }
159            let zeros: Vec<usize> = (0..n)
160                .filter(|&j| col_assign[j] == usize::MAX && c[i][j].abs() < 1e-10)
161                .collect();
162            if zeros.len() == 1 {
163                row_assign[i] = zeros[0];
164                col_assign[zeros[0]] = i;
165                changed = true;
166            }
167        }
168    }
169    // Second pass: assign remaining rows
170    for i in 0..n {
171        if row_assign[i] != usize::MAX {
172            continue;
173        }
174        for j in 0..n {
175            if col_assign[j] == usize::MAX && c[i][j].abs() < 1e-10 {
176                row_assign[i] = j;
177                col_assign[j] = i;
178                break;
179            }
180        }
181    }
182}
183
184/// Compute a minimum line cover of zeros using the standard Hungarian procedure:
185/// 1. Mark all unassigned rows.
186/// 2. For each marked row, mark columns with a zero in that row.
187/// 3. For each newly-marked column, mark the assigned row (if any).
188/// 4. Repeat until stable.
189/// Lines = unmarked rows ∪ marked columns.
190fn min_line_cover(
191    c: &[Vec<f64>],
192    row_assign: &[usize],
193    col_assign: &[usize],
194    n: usize,
195) -> (Vec<bool>, Vec<bool>) {
196    let mut marked_rows = vec![false; n];
197    let mut marked_cols = vec![false; n];
198
199    // Mark unassigned rows
200    for i in 0..n {
201        if row_assign[i] == usize::MAX {
202            marked_rows[i] = true;
203        }
204    }
205
206    let mut changed = true;
207    while changed {
208        changed = false;
209        // From marked rows: mark columns with zero
210        for i in 0..n {
211            if !marked_rows[i] {
212                continue;
213            }
214            for j in 0..n {
215                if !marked_cols[j] && c[i][j].abs() < 1e-10 {
216                    marked_cols[j] = true;
217                    changed = true;
218                    // From newly marked column: mark assigned row
219                    let r = col_assign[j];
220                    if r != usize::MAX && !marked_rows[r] {
221                        marked_rows[r] = true;
222                    }
223                }
224            }
225        }
226    }
227
228    // Lines: row_covered = !marked_rows,  col_covered = marked_cols
229    let row_covered: Vec<bool> = marked_rows.iter().map(|&m| !m).collect();
230    let col_covered = marked_cols;
231    (row_covered, col_covered)
232}
233
234/// Augmenting-path matching on zero positions (Hungarian matching phase).
235fn augment_assignment(
236    row: usize,
237    c: &[Vec<f64>],
238    row_assign: &mut Vec<usize>,
239    col_assign: &mut Vec<usize>,
240    visited_cols: &mut Vec<bool>,
241    n: usize,
242) -> bool {
243    for j in 0..n {
244        if visited_cols[j] || c[row][j].abs() >= 1e-10 {
245            continue;
246        }
247        visited_cols[j] = true;
248        let prev = col_assign[j];
249        if prev == usize::MAX
250            || augment_assignment(prev, c, row_assign, col_assign, visited_cols, n)
251        {
252            row_assign[row] = j;
253            col_assign[j] = row;
254            return true;
255        }
256    }
257    false
258}
259
260// ── General minimum-cost bipartite matching ───────────────────────────────────
261
262/// Minimum-cost bipartite matching via the Hungarian algorithm on a sparse
263/// edge list.
264///
265/// `n` = number of left vertices, `m` = number of right vertices.
266/// `edges` = list of `(left, right, cost)` triples.
267///
268/// Returns `(assignment, total_cost)` where `assignment[i]` is `Some(j)` if
269/// left vertex `i` is matched to right vertex `j`, or `None` if unmatched.
270///
271/// Uses an O(V·E) successive shortest path (Bellman-Ford style) approach
272/// suitable for sparse graphs.
273pub fn min_cost_matching(
274    n: usize,
275    m: usize,
276    edges: &[(usize, usize, f64)],
277) -> AssignmentResult<(Vec<Option<usize>>, f64)> {
278    if n == 0 || m == 0 {
279        return Ok((vec![None; n], 0.0));
280    }
281
282    // Validate edges
283    for &(u, v, _) in edges {
284        if u >= n {
285            return Err(OptimizeError::InvalidInput(format!(
286                "Left vertex {u} out of range [0, {n})"
287            )));
288        }
289        if v >= m {
290            return Err(OptimizeError::InvalidInput(format!(
291                "Right vertex {v} out of range [0, {m})"
292            )));
293        }
294    }
295
296    // Build dense cost matrix (infinity for missing edges)
297    let dim = n.max(m);
298    let mut cost_mat = Array2::<f64>::from_elem((dim, dim), f64::INFINITY);
299    for &(u, v, c) in edges {
300        // Keep the minimum cost if multiple edges between same pair
301        if c < cost_mat[[u, v]] {
302            cost_mat[[u, v]] = c;
303        }
304    }
305
306    // Replace infinity with a large finite value for the Hungarian algorithm
307    let max_finite = edges
308        .iter()
309        .map(|&(_, _, c)| c.abs())
310        .fold(0.0f64, f64::max);
311    let large = (max_finite + 1.0) * dim as f64 * 10.0;
312    for i in 0..dim {
313        for j in 0..dim {
314            if cost_mat[[i, j]].is_infinite() {
315                cost_mat[[i, j]] = large;
316            }
317        }
318    }
319
320    let (raw_assign, _) = hungarian_algorithm(&cost_mat)?;
321
322    // Build result: only include original (n×m) assignments that used real edges
323    let mut assignment = vec![None; n];
324    let mut total_cost = 0.0;
325
326    for i in 0..n {
327        let j = raw_assign[i];
328        if j < m && cost_mat[[i, j]] < large - 1e-9 {
329            assignment[i] = Some(j);
330            total_cost += cost_mat[[i, j]];
331        }
332    }
333
334    Ok((assignment, total_cost))
335}
336
337// ─────────────────────────────────────────────────────────────────────────────
338// Tests
339// ─────────────────────────────────────────────────────────────────────────────
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use scirs2_core::ndarray::array;
344
345    /// Verify that assignment is a permutation and total cost matches.
346    fn verify_assignment(cost: &Array2<f64>, assignment: &[usize], total: f64) {
347        let n = assignment.len();
348        let mut seen = vec![false; cost.ncols().max(n)];
349        for (i, &j) in assignment.iter().enumerate() {
350            assert!(!seen[j], "column {j} assigned twice");
351            seen[j] = true;
352            let _ = cost[[i, j]]; // bounds check
353        }
354        let computed: f64 = assignment
355            .iter()
356            .enumerate()
357            .map(|(i, &j)| cost[[i, j]])
358            .sum();
359        assert!(
360            (computed - total).abs() < 1e-6,
361            "cost mismatch: {computed} vs {total}"
362        );
363    }
364
365    #[test]
366    fn test_hungarian_3x3() {
367        // Classic 3×3 example; optimal = 4+3+4 or equivalent
368        let cost = array![[4.0, 1.0, 3.0], [2.0, 0.0, 5.0], [3.0, 2.0, 2.0]];
369        let (assign, total) = hungarian_algorithm(&cost).expect("unexpected None or Err");
370        verify_assignment(&cost, &assign, total);
371        // Optimal = 1+2+2 = 5 or 0+3+2 = 5  (both are 5)
372        assert!((total - 5.0).abs() < 1e-6, "expected 5.0 got {total}");
373    }
374
375    #[test]
376    fn test_hungarian_4x4_known_optimal() {
377        // Example from Burkard & Derigs; optimal = 55
378        let cost = array![
379            [9.0, 2.0, 7.0, 8.0],
380            [6.0, 4.0, 3.0, 7.0],
381            [5.0, 8.0, 1.0, 8.0],
382            [7.0, 6.0, 9.0, 4.0]
383        ];
384        let (assign, total) = hungarian_algorithm(&cost).expect("unexpected None or Err");
385        verify_assignment(&cost, &assign, total);
386        assert!(total <= 15.0 + 1e-6, "expected ≤15 got {total}");
387    }
388
389    #[test]
390    fn test_hungarian_identity() {
391        // Identity cost: optimal = 0
392        let cost = array![[0.0, 1.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 0.0]];
393        let (assign, total) = hungarian_algorithm(&cost).expect("unexpected None or Err");
394        verify_assignment(&cost, &assign, total);
395        assert!((total - 0.0).abs() < 1e-6);
396    }
397
398    #[test]
399    fn test_hungarian_1x1() {
400        let cost = array![[7.0]];
401        let (assign, total) = hungarian_algorithm(&cost).expect("unexpected None or Err");
402        assert_eq!(assign, vec![0]);
403        assert!((total - 7.0).abs() < 1e-6);
404    }
405
406    #[test]
407    fn test_hungarian_2x2() {
408        let cost = array![[4.0, 3.0], [3.0, 4.0]];
409        let (assign, total) = hungarian_algorithm(&cost).expect("unexpected None or Err");
410        verify_assignment(&cost, &assign, total);
411        assert!((total - 6.0).abs() < 1e-6, "expected 6.0 got {total}");
412    }
413
414    #[test]
415    fn test_hungarian_empty_error() {
416        let cost: Array2<f64> = Array2::zeros((0, 0));
417        assert!(hungarian_algorithm(&cost).is_err());
418    }
419
420    #[test]
421    fn test_min_cost_matching_basic() {
422        // Left {0,1}, Right {0,1}, edges: (0,0,1),(0,1,4),(1,0,2),(1,1,3)
423        // Optimal: 0→0 (1), 1→1 (3) = 4
424        let edges = vec![(0, 0, 1.0), (0, 1, 4.0), (1, 0, 2.0), (1, 1, 3.0)];
425        let (assign, total) = min_cost_matching(2, 2, &edges).expect("unexpected None or Err");
426        assert_eq!(assign[0], Some(0));
427        assert_eq!(assign[1], Some(1));
428        assert!((total - 4.0).abs() < 1e-6, "expected 4.0 got {total}");
429    }
430
431    #[test]
432    fn test_min_cost_matching_sparse() {
433        // Some edges missing; ensure no panic
434        let edges = vec![(0, 0, 1.0), (1, 1, 2.0)];
435        let (assign, total) = min_cost_matching(2, 2, &edges).expect("unexpected None or Err");
436        assert_eq!(assign[0], Some(0));
437        assert_eq!(assign[1], Some(1));
438        assert!((total - 3.0).abs() < 1e-6);
439    }
440
441    #[test]
442    fn test_min_cost_matching_invalid_vertex() {
443        let edges = vec![(0, 5, 1.0)]; // right vertex out of range
444        assert!(min_cost_matching(2, 3, &edges).is_err());
445    }
446
447    #[test]
448    fn test_hungarian_rectangular_3x2() {
449        // 3 rows, 2 columns (padded to 3×3 internally)
450        let cost = array![[5.0, 2.0], [3.0, 4.0], [1.0, 6.0]];
451        let (assign, total) = hungarian_algorithm(&cost).expect("unexpected None or Err");
452        // assignment has 3 entries; some may point to padded column (2)
453        assert_eq!(assign.len(), 3);
454        // Real-column assignments should be feasible
455        let real_assigns: Vec<usize> = assign.iter().cloned().filter(|&j| j < 2).collect();
456        assert_eq!(
457            real_assigns.len(),
458            2,
459            "expected exactly 2 real-column assignments"
460        );
461        let _ = total;
462    }
463}