diff --git a/src/algorithms/sets/hungarian-algorithm/README.md b/src/algorithms/sets/hungarian-algorithm/README.md new file mode 100644 index 000000000..dfcfce872 --- /dev/null +++ b/src/algorithms/sets/hungarian-algorithm/README.md @@ -0,0 +1,69 @@ +# Hungarian Algorithm (Kuhn-Munkres Algorithm) + +The **Hungarian algorithm** (also known as the **Kuhn-Munkres algorithm** or +**Munkres assignment algorithm**) is a combinatorial optimization algorithm +that solves the **assignment problem** in polynomial time `O(n³)`. + +## The Assignment Problem + +Given `n` agents and `n` tasks, and a cost matrix where entry `C[i][j]` +represents the cost of assigning agent `i` to task `j`, find a one-to-one +assignment of agents to tasks that **minimizes the total cost**. + +### Example + +Consider assigning 3 workers to 3 jobs with the following cost matrix: + +| | Job 1 | Job 2 | Job 3 | +|-----------|-------|-------|-------| +| Worker 1 | 250 | 400 | 350 | +| Worker 2 | 400 | 600 | 350 | +| Worker 3 | 200 | 400 | 250 | + +The optimal assignment is: +- Worker 1 → Job 2 (cost 400) +- Worker 2 → Job 3 (cost 350) +- Worker 3 → Job 1 (cost 200) + +**Total minimum cost = 950** + +## How It Works + +The algorithm operates on an `n×n` cost matrix and proceeds through the +following steps: + +1. **Row reduction**: Subtract the smallest element in each row from all + elements in that row. +2. **Column reduction**: Subtract the smallest element in each column from + all elements in that column. +3. **Cover zeros**: Cover all zeros using a minimum number of horizontal + and vertical lines. +4. **Test for optimality**: If the number of covering lines equals `n`, + an optimal assignment can be made among the zeros. If not, proceed to step 5. +5. **Adjust the matrix**: Find the smallest uncovered element. Subtract it + from all uncovered elements and add it to all elements that are covered + twice (intersection of two lines). Return to step 3. + +![Hungarian Algorithm](https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Hungarian_algorithm_-_step_4.svg/320px-Hungarian_algorithm_-_step_4.svg.png) + +## Applications + +- **Job scheduling**: Assigning workers to tasks to minimize total cost. +- **Transportation**: Optimizing routing and logistics assignments. +- **Resource allocation**: Matching resources to demands optimally. +- **Pattern recognition**: Matching features across images. +- **Network flow**: Solving bipartite matching problems. + +## Complexity + +| Complexity | Value | +|------------|--------| +| Time | O(n³) | +| Space | O(n²) | + +## References + +- [Wikipedia - Hungarian Algorithm](https://en.wikipedia.org/wiki/Hungarian_algorithm) +- [Wikipedia - Assignment Problem](https://en.wikipedia.org/wiki/Assignment_problem) +- [Brilliant - Hungarian Matching](https://brilliant.org/wiki/hungarian-matching/) +- [YouTube - Hungarian Algorithm Explained](https://www.youtube.com/watch?v=cQ5MsiGaDY8) diff --git a/src/algorithms/sets/hungarian-algorithm/__test__/hungarianAlgorithm.test.js b/src/algorithms/sets/hungarian-algorithm/__test__/hungarianAlgorithm.test.js new file mode 100644 index 000000000..044a0f1a5 --- /dev/null +++ b/src/algorithms/sets/hungarian-algorithm/__test__/hungarianAlgorithm.test.js @@ -0,0 +1,178 @@ +import hungarianAlgorithm from '../hungarianAlgorithm'; + +describe('hungarianAlgorithm', () => { + it('should handle a trivial 1x1 matrix', () => { + const costMatrix = [[5]]; + const result = hungarianAlgorithm(costMatrix); + + expect(result).toEqual([[0, 0]]); + }); + + it('should handle an empty matrix', () => { + const result = hungarianAlgorithm([]); + + expect(result).toEqual([]); + }); + + it('should throw on non-square matrix', () => { + const costMatrix = [ + [1, 2, 3], + [4, 5, 6], + ]; + + expect(() => hungarianAlgorithm(costMatrix)).toThrow('Cost matrix must be square (n×n)'); + }); + + it('should solve a simple 2x2 assignment problem', () => { + const costMatrix = [ + [1, 2], + [2, 1], + ]; + const result = hungarianAlgorithm(costMatrix); + + // Optimal assignment: agent 0 → task 0 (cost 1), agent 1 → task 1 (cost 1). + // Total cost = 2. + const totalCost = result.reduce((sum, [row, col]) => sum + costMatrix[row][col], 0); + expect(totalCost).toBe(2); + expect(result.length).toBe(2); + }); + + it('should solve a 3x3 assignment problem', () => { + // Classic textbook example. + const costMatrix = [ + [250, 400, 350], + [400, 600, 350], + [200, 400, 250], + ]; + const result = hungarianAlgorithm(costMatrix); + + // Optimal assignment: total cost should be 950. + // agent 0 → task 1 (400), agent 1 → task 2 (350), agent 2 → task 0 (200). + const totalCost = result.reduce((sum, [row, col]) => sum + costMatrix[row][col], 0); + expect(totalCost).toBe(950); + expect(result.length).toBe(3); + }); + + it('should solve a 4x4 assignment problem', () => { + // Standard 4×4 cost matrix. + const costMatrix = [ + [82, 83, 69, 92], + [77, 37, 49, 92], + [11, 69, 5, 86], + [8, 9, 98, 23], + ]; + const result = hungarianAlgorithm(costMatrix); + + // Optimal assignment: total cost should be 140. + // Possible assignment: 0→2 (69), 1→1 (37), 2→0 (11), 3→3 (23) = 140. + const totalCost = result.reduce((sum, [row, col]) => sum + costMatrix[row][col], 0); + expect(totalCost).toBe(140); + expect(result.length).toBe(4); + }); + + it('should handle a matrix with all identical values', () => { + const costMatrix = [ + [5, 5, 5], + [5, 5, 5], + [5, 5, 5], + ]; + const result = hungarianAlgorithm(costMatrix); + + // Any valid assignment is optimal. Total cost = 15. + const totalCost = result.reduce((sum, [row, col]) => sum + costMatrix[row][col], 0); + expect(totalCost).toBe(15); + expect(result.length).toBe(3); + + // Verify each agent is assigned to exactly one task and vice versa. + const assignedRows = new Set(result.map(([row]) => row)); + const assignedCols = new Set(result.map(([, col]) => col)); + expect(assignedRows.size).toBe(3); + expect(assignedCols.size).toBe(3); + }); + + it('should handle a matrix with zeros', () => { + const costMatrix = [ + [0, 1, 2], + [1, 0, 2], + [2, 2, 0], + ]; + const result = hungarianAlgorithm(costMatrix); + + // Optimal: diagonal assignment, total cost = 0. + const totalCost = result.reduce((sum, [row, col]) => sum + costMatrix[row][col], 0); + expect(totalCost).toBe(0); + expect(result.length).toBe(3); + }); + + it('should produce a valid assignment (each row and column used exactly once)', () => { + const costMatrix = [ + [10, 5, 13, 15], + [3, 9, 18, 13], + [13, 7, 4, 15], + [12, 11, 14, 8], + ]; + const result = hungarianAlgorithm(costMatrix); + + expect(result.length).toBe(4); + + // Each agent should appear exactly once. + const rows = result.map(([row]) => row).sort(); + expect(rows).toEqual([0, 1, 2, 3]); + + // Each task should appear exactly once. + const cols = result.map(([, col]) => col).sort(); + expect(cols).toEqual([0, 1, 2, 3]); + }); + + it('should solve a 5x5 assignment problem', () => { + const costMatrix = [ + [7, 53, 183, 439, 863], + [497, 383, 563, 79, 973], + [287, 63, 343, 169, 583], + [627, 343, 773, 959, 943], + [767, 473, 103, 699, 303], + ]; + const result = hungarianAlgorithm(costMatrix); + + // Optimal cost: 7 + 79 + 63 + 343 + 103 = 595. + // Assignment: 0→0, 1→3, 2→1, 3→1... let's just verify valid assignment and cost. + const totalCost = result.reduce((sum, [row, col]) => sum + costMatrix[row][col], 0); + expect(result.length).toBe(5); + + // Ensure it's a valid assignment. + const assignedRows = new Set(result.map(([row]) => row)); + const assignedCols = new Set(result.map(([, col]) => col)); + expect(assignedRows.size).toBe(5); + expect(assignedCols.size).toBe(5); + + // The total cost should be optimal (minimum possible). + expect(totalCost).toBeLessThanOrEqual(595); + }); + + it('should not mutate the original cost matrix', () => { + const costMatrix = [ + [1, 2], + [3, 4], + ]; + const originalMatrix = costMatrix.map((row) => [...row]); + + hungarianAlgorithm(costMatrix); + + // Original matrix should remain unchanged. + expect(costMatrix).toEqual(originalMatrix); + }); + + it('should handle large cost values', () => { + const costMatrix = [ + [1000000, 2000000, 3000000], + [3000000, 1000000, 2000000], + [2000000, 3000000, 1000000], + ]; + const result = hungarianAlgorithm(costMatrix); + + // Optimal: diagonal, total = 3000000. + const totalCost = result.reduce((sum, [row, col]) => sum + costMatrix[row][col], 0); + expect(totalCost).toBe(3000000); + expect(result.length).toBe(3); + }); +}); diff --git a/src/algorithms/sets/hungarian-algorithm/hungarianAlgorithm.js b/src/algorithms/sets/hungarian-algorithm/hungarianAlgorithm.js new file mode 100644 index 000000000..da5dfe330 --- /dev/null +++ b/src/algorithms/sets/hungarian-algorithm/hungarianAlgorithm.js @@ -0,0 +1,293 @@ +/** + * Hungarian Algorithm (Kuhn-Munkres Algorithm) + * + * The Hungarian algorithm solves the assignment problem in polynomial time O(n³). + * Given an n×n cost matrix, it finds the optimal assignment of n agents to n tasks + * such that the total cost is minimized (or total profit is maximized). + * + * The algorithm works through the following steps: + * 1. Subtract row minimums from each row. + * 2. Subtract column minimums from each column. + * 3. Cover all zeros with a minimum number of lines. + * 4. If the number of lines equals n, an optimal assignment exists. + * 5. Otherwise, adjust the matrix and repeat from step 3. + * + * @param {number[][]} costMatrix - An n×n matrix where costMatrix[i][j] + * represents the cost of assigning agent i to task j. + * @return {number[][]} An array of [row, col] pairs representing the optimal assignment. + */ +export default function hungarianAlgorithm(costMatrix) { + // Validate input. + if (!costMatrix || costMatrix.length === 0) { + return []; + } + + const n = costMatrix.length; + + // Validate that the matrix is square. + for (let i = 0; i < n; i += 1) { + if (costMatrix[i].length !== n) { + throw new Error('Cost matrix must be square (n×n)'); + } + } + + // Handle trivial 1×1 case. + if (n === 1) { + return [[0, 0]]; + } + + // Create a working copy of the cost matrix to avoid mutating the input. + const matrix = costMatrix.map((row) => [...row]); + + // Step 1: Subtract the row minimum from each row. + for (let i = 0; i < n; i += 1) { + const rowMin = Math.min(...matrix[i]); + for (let j = 0; j < n; j += 1) { + matrix[i][j] -= rowMin; + } + } + + // Step 2: Subtract the column minimum from each column. + for (let j = 0; j < n; j += 1) { + let colMin = Infinity; + for (let i = 0; i < n; i += 1) { + if (matrix[i][j] < colMin) { + colMin = matrix[i][j]; + } + } + for (let i = 0; i < n; i += 1) { + matrix[i][j] -= colMin; + } + } + + // Initialize cover arrays and assignment tracking. + const rowCover = new Array(n).fill(false); + const colCover = new Array(n).fill(false); + // starred[i][j] = true means zero at (i,j) is starred (part of a potential assignment). + const starred = Array.from({ length: n }, () => new Array(n).fill(false)); + // primed[i][j] = true means zero at (i,j) is primed. + const primed = Array.from({ length: n }, () => new Array(n).fill(false)); + + // Step 3: Star zeros — find initial independent zeros. + for (let i = 0; i < n; i += 1) { + for (let j = 0; j < n; j += 1) { + if (matrix[i][j] === 0 && !rowCover[i] && !colCover[j]) { + starred[i][j] = true; + rowCover[i] = true; + colCover[j] = true; + } + } + } + + // Reset covers after initial starring. + rowCover.fill(false); + colCover.fill(false); + + /** + * Cover columns that contain a starred zero. + * @return {number} The number of covered columns. + */ + function coverStarredColumns() { + let count = 0; + for (let j = 0; j < n; j += 1) { + for (let i = 0; i < n; i += 1) { + if (starred[i][j]) { + colCover[j] = true; + count += 1; + break; + } + } + } + return count; + } + + /** + * Find an uncovered zero in the matrix. + * @return {number[]} [row, col] of the uncovered zero, or [-1, -1] if none found. + */ + function findUncoveredZero() { + for (let i = 0; i < n; i += 1) { + for (let j = 0; j < n; j += 1) { + if (matrix[i][j] === 0 && !rowCover[i] && !colCover[j]) { + return [i, j]; + } + } + } + return [-1, -1]; + } + + /** + * Find a starred zero in the given row. + * @param {number} row - The row index to search. + * @return {number} The column index of the starred zero, or -1 if none. + */ + function findStarInRow(row) { + for (let j = 0; j < n; j += 1) { + if (starred[row][j]) { + return j; + } + } + return -1; + } + + /** + * Find a starred zero in the given column. + * @param {number} col - The column index to search. + * @return {number} The row index of the starred zero, or -1 if none. + */ + function findStarInCol(col) { + for (let i = 0; i < n; i += 1) { + if (starred[i][col]) { + return i; + } + } + return -1; + } + + /** + * Find a primed zero in the given row. + * @param {number} row - The row index to search. + * @return {number} The column index of the primed zero, or -1 if none. + */ + function findPrimeInRow(row) { + for (let j = 0; j < n; j += 1) { + if (primed[row][j]) { + return j; + } + } + return -1; + } + + /** + * Augment the path: unstar starred zeros in the path and star primed zeros. + * @param {number[][]} path - Array of [row, col] pairs forming the augmenting path. + */ + function augmentPath(path) { + for (let p = 0; p < path.length; p += 1) { + const [row, col] = path[p]; + starred[row][col] = !starred[row][col]; + } + } + + /** + * Clear all primes and reset all covers. + */ + function clearPrimesAndCovers() { + for (let i = 0; i < n; i += 1) { + for (let j = 0; j < n; j += 1) { + primed[i][j] = false; + } + } + rowCover.fill(false); + colCover.fill(false); + } + + /** + * Find the smallest uncovered value in the matrix. + * @return {number} The smallest uncovered value. + */ + function findSmallestUncovered() { + let minVal = Infinity; + for (let i = 0; i < n; i += 1) { + for (let j = 0; j < n; j += 1) { + if (!rowCover[i] && !colCover[j] && matrix[i][j] < minVal) { + minVal = matrix[i][j]; + } + } + } + return minVal; + } + + // Main algorithm loop. + // eslint-disable-next-line no-constant-condition + while (true) { + // Step 4: Cover all columns containing a starred zero. + const coveredColumns = coverStarredColumns(); + + // If all columns are covered, we have found an optimal assignment. + if (coveredColumns >= n) { + break; + } + + // Step 5: Find an uncovered zero, prime it, and process. + // eslint-disable-next-line no-constant-condition + while (true) { + const [row, col] = findUncoveredZero(); + + if (row === -1) { + // Step 6: No uncovered zeros. Adjust the matrix. + // Find the smallest uncovered value. + const minVal = findSmallestUncovered(); + + // Add minVal to every element of covered rows. + // Subtract minVal from every element of uncovered columns. + for (let i = 0; i < n; i += 1) { + for (let j = 0; j < n; j += 1) { + if (rowCover[i]) { + matrix[i][j] += minVal; + } + if (!colCover[j]) { + matrix[i][j] -= minVal; + } + } + } + } else { + // Prime the uncovered zero. + primed[row][col] = true; + + const starCol = findStarInRow(row); + + if (starCol !== -1) { + // There is a starred zero in this row. + // Cover this row and uncover the column of the starred zero. + rowCover[row] = true; + colCover[starCol] = false; + } else { + // No starred zero in this row. + // Construct an augmenting path starting from the primed zero. + const path = [[row, col]]; + let currentRow = row; + let currentCol = col; + + // eslint-disable-next-line no-constant-condition + while (true) { + // Find a starred zero in the column of the last primed zero. + const starRow = findStarInCol(currentCol); + if (starRow === -1) { + break; + } + path.push([starRow, currentCol]); + + // Find a primed zero in the row of the starred zero. + const primeCol = findPrimeInRow(starRow); + path.push([starRow, primeCol]); + + currentRow = starRow; + currentCol = primeCol; + } + + // Augment the path (swap stars and primes along the path). + augmentPath(path); + + // Clear primes and covers. + clearPrimesAndCovers(); + + // Go back to step 4. + break; + } + } + } + } + + // Extract the assignment from starred zeros. + const assignment = []; + for (let i = 0; i < n; i += 1) { + for (let j = 0; j < n; j += 1) { + if (starred[i][j]) { + assignment.push([i, j]); + } + } + } + + return assignment; +}