Skip to main content

converge_optimization/assignment/
hungarian.rs

1//! Hungarian Algorithm for the Linear Assignment Problem
2//!
3//! The Hungarian algorithm (also known as Kuhn-Munkres) finds the optimal
4//! assignment in O(n³) time for an n×n cost matrix.
5//!
6//! ## Algorithm Overview
7//!
8//! 1. Subtract row minima from each row
9//! 2. Subtract column minima from each column
10//! 3. Cover all zeros with minimum lines
11//! 4. If lines < n, create more zeros and repeat
12//! 5. Find optimal assignment using covered zeros
13
14use super::{AssignmentProblem, AssignmentSolution, AssignmentSolver};
15use crate::{Cost, Error, Result, SolverParams, SolverStats, SolverStatus};
16use std::time::Instant;
17
18/// Hungarian algorithm solver
19pub struct HungarianSolver;
20
21impl AssignmentSolver for HungarianSolver {
22    fn solve(
23        &self,
24        problem: &AssignmentProblem,
25        params: &SolverParams,
26    ) -> Result<AssignmentSolution> {
27        solve_hungarian(problem, params)
28    }
29
30    fn name(&self) -> &'static str {
31        "hungarian"
32    }
33}
34
35/// Solve using Hungarian algorithm
36pub fn solve(problem: &AssignmentProblem) -> Result<AssignmentSolution> {
37    solve_hungarian(problem, &SolverParams::default())
38}
39
40fn solve_hungarian(
41    problem: &AssignmentProblem,
42    params: &SolverParams,
43) -> Result<AssignmentSolution> {
44    let start = Instant::now();
45
46    let n = problem.num_agents;
47    let m = problem.num_tasks;
48
49    if n == 0 || m == 0 {
50        return Err(Error::invalid_input("empty problem"));
51    }
52
53    // For rectangular problems, pad to square
54    let size = n.max(m);
55    let mut cost = vec![vec![0i64; size]; size];
56
57    // Copy costs (use large value for padding)
58    let large = problem
59        .costs
60        .iter()
61        .flat_map(|row| row.iter())
62        .max()
63        .copied()
64        .unwrap_or(0)
65        .saturating_add(1);
66
67    for i in 0..size {
68        for j in 0..size {
69            cost[i][j] = if i < n && j < m {
70                problem.costs[i][j]
71            } else {
72                large // Padding cost
73            };
74        }
75    }
76
77    // u[i] and v[j] are the dual variables (potentials)
78    let mut u = vec![0i64; size + 1];
79    let mut v = vec![0i64; size + 1];
80
81    // p[j] = agent assigned to task j (0 means unassigned, 1-indexed)
82    let mut p = vec![0usize; size + 1];
83
84    // way[j] = previous task in augmenting path to j
85    let mut way = vec![0usize; size + 1];
86
87    let mut iterations = 0;
88
89    for i in 1..=size {
90        // Check timeout
91        if params.has_time_limit() && start.elapsed().as_secs_f64() > params.time_limit_seconds {
92            return Err(Error::timeout(params.time_limit_seconds));
93        }
94
95        p[0] = i;
96        let mut j0 = 0usize; // Current task (0 = virtual)
97
98        let mut minv = vec![i64::MAX; size + 1];
99        let mut used = vec![false; size + 1];
100
101        // Find augmenting path
102        loop {
103            iterations += 1;
104            used[j0] = true;
105            let i0 = p[j0];
106            let mut delta = i64::MAX;
107            let mut j1 = 0usize;
108
109            for j in 1..=size {
110                if !used[j] {
111                    let cur = cost[i0 - 1][j - 1] - u[i0] - v[j];
112                    if cur < minv[j] {
113                        minv[j] = cur;
114                        way[j] = j0;
115                    }
116                    if minv[j] < delta {
117                        delta = minv[j];
118                        j1 = j;
119                    }
120                }
121            }
122
123            // Update potentials
124            for j in 0..=size {
125                if used[j] {
126                    u[p[j]] += delta;
127                    v[j] -= delta;
128                } else {
129                    minv[j] -= delta;
130                }
131            }
132
133            j0 = j1;
134            if p[j0] == 0 {
135                break;
136            }
137        }
138
139        // Reconstruct path
140        loop {
141            let j1 = way[j0];
142            p[j0] = p[j1];
143            j0 = j1;
144            if j0 == 0 {
145                break;
146            }
147        }
148    }
149
150    // Extract solution (convert from task->agent to agent->task)
151    let mut assignments = vec![0usize; n];
152    let mut total_cost: Cost = 0;
153
154    for j in 1..=size {
155        if p[j] != 0 && p[j] <= n && j <= m {
156            let agent = p[j] - 1;
157            let task = j - 1;
158            assignments[agent] = task;
159            total_cost += problem.costs[agent][task];
160        }
161    }
162
163    let elapsed = start.elapsed().as_secs_f64();
164
165    Ok(AssignmentSolution {
166        assignments,
167        total_cost,
168        status: SolverStatus::Optimal,
169        stats: SolverStats {
170            solve_time_seconds: elapsed,
171            iterations,
172            objective_value: Some(total_cost as f64),
173            ..Default::default()
174        },
175    })
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_3x3() {
184        let problem =
185            AssignmentProblem::from_costs(vec![vec![10, 5, 13], vec![3, 9, 18], vec![14, 8, 7]]);
186        let solution = solve(&problem).unwrap();
187        assert_eq!(solution.total_cost, 15);
188        assert_eq!(solution.status, SolverStatus::Optimal);
189    }
190
191    #[test]
192    fn test_1x1() {
193        let problem = AssignmentProblem::from_costs(vec![vec![42]]);
194        let solution = solve(&problem).unwrap();
195        assert_eq!(solution.total_cost, 42);
196        assert_eq!(solution.assignments, vec![0]);
197    }
198
199    #[test]
200    fn test_2x2() {
201        let problem = AssignmentProblem::from_costs(vec![vec![1, 2], vec![3, 4]]);
202        let solution = solve(&problem).unwrap();
203        // Optimal: (0,0)=1 + (1,1)=4 = 5, or (0,1)=2 + (1,0)=3 = 5
204        assert_eq!(solution.total_cost, 5);
205    }
206
207    #[test]
208    fn test_negative_costs() {
209        let problem = AssignmentProblem::from_costs(vec![vec![-1, -2], vec![-3, -4]]);
210        let solution = solve(&problem).unwrap();
211        // Optimal: minimize -> most negative = -1 + -4 = -5 or -2 + -3 = -5
212        assert_eq!(solution.total_cost, -5);
213    }
214
215    #[test]
216    fn test_larger() {
217        let problem = AssignmentProblem::from_costs(vec![
218            vec![7, 53, 183, 439],
219            vec![497, 383, 563, 79],
220            vec![627, 343, 773, 959],
221            vec![447, 283, 463, 29],
222        ]);
223        let solution = solve(&problem).unwrap();
224        // Known optimal: 7 + 383 + 773 + 29 = 1192? Let's verify
225        // Actually: 7 + 79 + 343 + 463 = 892 is better
226        // Best: agent0->task0(7), agent1->task3(79), agent2->task1(343), agent3->task2(463) = 892
227        assert!(solution.total_cost <= 892);
228    }
229}