diff --git a/src/main/java/org/graph4j/alg/assignment/HungarianAlgorithm.java b/src/main/java/org/graph4j/alg/assignment/HungarianAlgorithm.java
new file mode 100644
index 0000000..bb4c24e
--- /dev/null
+++ b/src/main/java/org/graph4j/alg/assignment/HungarianAlgorithm.java
@@ -0,0 +1,267 @@
+package org.graph4j.alg.assignment;
+
+
+import org.graph4j.Graph;
+import org.graph4j.alg.UndirectedGraphAlgorithm;
+import org.graph4j.alg.bipartite.BipartitionAlgorithm;
+import org.graph4j.util.IntArrays;
+import org.graph4j.util.Matching;
+import org.graph4j.util.StableSet;
+
+import java.util.Arrays;
+
+/**
+ * The Hungarian Algorithm, also known as the Kuhn-Munkres algorithm,
+ * is a combinatorial optimization algorithm used to solve the assignment problem
+ *
+ * The algorithm finds a matching and a maximum potential such that the
+ * matching cost is equal to the potential value.
+ *
+ * @author Chirvasa Matei
+ * @author Prodan Sabina
+ */
+public class HungarianAlgorithm extends UndirectedGraphAlgorithm {
+
+ private final StableSet workerSide;
+ private final StableSet taskSide;
+ private Matching matching;
+ private Boolean isDense;
+
+ /**
+ * Instantiates a new Hungarian algorithm.
+ *
+ * @param graph the input graph, must be bipartite
+ */
+ public HungarianAlgorithm(Graph graph) {
+ super(graph);
+ var alg = BipartitionAlgorithm.getInstance(graph);
+ if (!alg.isBipartite()) {
+ throw new IllegalArgumentException("The graph is not bipartite");
+ }
+ StableSet leftSide = alg.getLeftSide(), rightSide = alg.getRightSide();
+ // algorithm requires that there be more workers than tasks when assigning
+ if (leftSide.size() < rightSide.size()) {
+ this.workerSide = rightSide;
+ this.taskSide = leftSide;
+ }
+ else {
+ this.workerSide = leftSide;
+ this.taskSide = rightSide;
+ }
+ }
+
+ /**
+ * Instantiates a new Hungarian algorithm.
+ *
+ * If |leftSide| = |rightSide|, the former will represent the worker set,
+ * and the latter the tasks.
+ * Otherwise, the side with more elements will make up the workers.
+ *
+ * @param graph the input graph, on which the bipartitions were built
+ * @param leftSide the left side of the bipartition
+ * @param rightSide the right side of the bipartition
+ */
+ public HungarianAlgorithm(Graph graph, StableSet leftSide, StableSet rightSide) {
+ super(graph);
+ if (!leftSide.isValid()) {
+ throw new IllegalArgumentException("The left side is not a stable set.");
+ }
+ if (!rightSide.isValid()) {
+ throw new IllegalArgumentException("The right side is not a stable set.");
+ }
+ // algorithm requires that there be more workers than tasks when assigning
+ if (leftSide.size() < rightSide.size()) {
+ this.workerSide = rightSide;
+ this.taskSide = leftSide;
+ }
+ else {
+ this.workerSide = leftSide;
+ this.taskSide = rightSide;
+ }
+ int[] vertices = IntArrays.union(workerSide.vertices(), rightSide.vertices());
+ if (!IntArrays.sameValues(vertices, graph.vertices())) {
+ throw new IllegalArgumentException("Invalid bipartition");
+ }
+ }
+
+ private boolean isDense() {
+ if (isDense == null) {
+ isDense = ((double) graph.numEdges() / ((long) graph.numVertices() * (graph.numVertices() - 1))) > 0.1;
+ }
+ return isDense;
+ }
+
+ private void computeSparse() {
+ final double INF = Double.MAX_VALUE;
+
+ int[] workerVertices = workerSide.vertices();
+ int[] taskVertices = taskSide.vertices();
+
+ int[] taskAssignment = new int[workerVertices.length + 1];
+ Arrays.fill(taskAssignment, -1);
+ double[] johnsonPotentials = new double[workerVertices.length + 1];
+
+ double[] distances = new double[workerVertices.length + 1];
+ boolean[] visited = new boolean[workerVertices.length + 1];
+ int[] previousWorker = new int[workerVertices.length + 1];
+
+ for (int taskIndex = 0; taskIndex < taskVertices.length; ++taskIndex) {
+ int currentWorker = workerVertices.length;
+ taskAssignment[currentWorker] = taskIndex;
+
+ Arrays.fill(distances, INF);
+ distances[currentWorker] = 0;
+ Arrays.fill(visited, false);
+ Arrays.fill(previousWorker, -1);
+ while (taskAssignment[currentWorker] != -1) {
+ double minDistance = INF;
+ visited[currentWorker] = true;
+ int nextWorker = -1;
+
+ for (int workerIndex = 0; workerIndex < workerVertices.length; ++workerIndex) {
+ if (visited[workerIndex]) {
+ continue;
+ }
+ double assignmentCost = graph.getEdgeWeight(taskVertices[taskAssignment[currentWorker]], workerVertices[workerIndex]) - johnsonPotentials[workerIndex];
+ if (currentWorker != workerVertices.length) {
+ assignmentCost -= graph.getEdgeWeight(taskVertices[taskAssignment[currentWorker]], workerVertices[currentWorker]) - johnsonPotentials[currentWorker];
+ }
+ if (distances[workerIndex] > distances[currentWorker] + assignmentCost) {
+ distances[workerIndex] = distances[currentWorker] + assignmentCost;
+ previousWorker[workerIndex] = currentWorker;
+ }
+ if (minDistance > distances[workerIndex]) {
+ minDistance = distances[workerIndex];
+ nextWorker = workerIndex;
+ }
+ }
+ currentWorker = nextWorker;
+ }
+ updateDistancesAndPotentials(taskAssignment, johnsonPotentials, distances, previousWorker, currentWorker);
+ }
+
+ produceMatching(workerVertices, taskVertices, taskAssignment);
+ }
+
+ private void produceMatching(int[] workerVertices, int[] taskVertices, int[] taskAssignment) {
+ matching = new Matching(graph, taskVertices.length);
+ for (int i = 0; i < workerVertices.length; ++i) {
+ if (taskAssignment[i] != -1) {
+ matching.add(workerVertices[i], taskVertices[taskAssignment[i]]);
+ }
+ }
+ }
+
+ private void computeDense() {
+ final double INF = Double.MAX_VALUE;
+
+ int[] workerVertices = workerSide.vertices();
+ int[] taskVertices = taskSide.vertices();
+
+ // cache costs into a matrix to increase efficiency
+ double[][] costs = new double[taskVertices.length][workerVertices.length];
+ Arrays.stream(costs).forEach(a -> Arrays.fill(a, INF));
+ for (int i = 0; i < taskVertices.length; ++i) {
+ for (int j = 0; j < workerVertices.length; ++j) {
+ // access edges by index in leftSide and rightSide to account for complicated graphs
+ costs[i][j] = graph.getEdgeWeight(taskVertices[i], workerVertices[j]);
+ }
+ }
+
+ // adding a surplus worker for convenience
+ int[] taskAssignment = new int[workerVertices.length + 1];
+ Arrays.fill(taskAssignment, -1);
+ double[] johnsonPotentials = new double[workerVertices.length + 1];
+
+ double[] distances = new double[workerVertices.length + 1];
+ boolean[] visited = new boolean[workerVertices.length + 1];
+ int[] previousWorker = new int[workerVertices.length + 1];
+
+ // assign the indexed task to a worker using Dijkstra with potentials
+ for (int taskIndex = 0; taskIndex < taskVertices.length; ++taskIndex) {
+ int currentWorker = workerVertices.length; // the surplus worker
+ taskAssignment[currentWorker] = taskIndex; // assign surplus worker to the current task
+
+ Arrays.fill(distances, INF); // johnson reduced distances
+ distances[currentWorker] = 0;
+ Arrays.fill(visited, false);
+ Arrays.fill(previousWorker, -1); // previous worker on the shortest path
+ while (taskAssignment[currentWorker] != -1) { // Dijkstra: Pop the minimum worker from the heap
+ double minDistance = INF;
+ visited[currentWorker] = true;
+ int nextWorker = -1; // next unvisited worker with minimum distance
+
+ // consider extending the shortest path by currentWorker -> taskAssignment[currentWorker] -> workerIndex
+ for (int workerIndex = 0; workerIndex < workerVertices.length; ++workerIndex) {
+ if (visited[workerIndex]) {
+ continue;
+ }
+ // sum of reduced edge weights by following currentWorker -> taskAssignment[currentWorker] -> workerIndex
+ double assignmentCost = costs[taskAssignment[currentWorker]][workerIndex] - johnsonPotentials[workerIndex];
+ if (currentWorker != workerVertices.length) {
+ assignmentCost -= costs[taskAssignment[currentWorker]][currentWorker] - johnsonPotentials[currentWorker];
+ }
+ if (distances[workerIndex] > distances[currentWorker] + assignmentCost) {
+ distances[workerIndex] = distances[currentWorker] + assignmentCost;
+ previousWorker[workerIndex] = currentWorker;
+ }
+ if (minDistance > distances[workerIndex]) {
+ minDistance = distances[workerIndex];
+ nextWorker = workerIndex;
+ }
+ }
+ currentWorker = nextWorker;
+ }
+ updateDistancesAndPotentials(taskAssignment, johnsonPotentials, distances, previousWorker, currentWorker);
+ }
+
+ produceMatching(workerVertices, taskVertices, taskAssignment);
+ }
+
+ private void updateDistancesAndPotentials(int[] taskAssignment, double[] johnsonPotentials, double[] distances, int[] previousWorker, int currentWorker) {
+ for (int workerIndex = 0; workerIndex < workerSide.size(); ++workerIndex) {
+ distances[workerIndex] = Double.min(distances[workerIndex], distances[currentWorker]);
+ johnsonPotentials[workerIndex] += distances[workerIndex];
+ }
+ for (int workerIndex = 0; workerIndex != workerSide.size(); currentWorker = workerIndex) {
+ workerIndex = previousWorker[currentWorker];
+ taskAssignment[currentWorker] = taskAssignment[workerIndex];
+ }
+ }
+
+ private void compute() {
+ if (isDense()) {
+ computeDense();
+ }
+ else {
+ computeSparse();
+ }
+ }
+
+ /**
+ * This algorithm performs better on dense graphs in terms of speed of execution,
+ * however it consumes more memory. More precisely, the costs of the edges
+ * will be cached, in a 2-dimensional array of doubles, of size |workers| * |tasks|
+ * Setting this fields before computation occurs forgoes the
+ * recommended implementation in favor of the selected one.
+ *
+ * @param isDense marks whether the graph is dense or not
+ */
+ public void setDense(boolean isDense) {
+ this.isDense = isDense;
+ }
+
+ /**
+ * Calls the algorithm to determine the lowest cost assignment possible for the
+ * given problem. If {@code setDense} was not called, will deduce the appropriate
+ * implementation to use.
+ *
+ * @return the matching that represents the lowest cost assignment possible
+ */
+ public Matching getMatching() {
+ if (matching == null) {
+ compute();
+ }
+ return matching;
+ }
+}
diff --git a/src/main/java/org/graph4j/alg/coloring/DsaturGreedyColoring.java b/src/main/java/org/graph4j/alg/coloring/DSaturGreedyColoring.java
similarity index 100%
rename from src/main/java/org/graph4j/alg/coloring/DsaturGreedyColoring.java
rename to src/main/java/org/graph4j/alg/coloring/DSaturGreedyColoring.java
diff --git a/src/test/java/org/graph4j/alg/HungarianAlgorithmTest.java b/src/test/java/org/graph4j/alg/HungarianAlgorithmTest.java
new file mode 100644
index 0000000..2e1463a
--- /dev/null
+++ b/src/test/java/org/graph4j/alg/HungarianAlgorithmTest.java
@@ -0,0 +1,47 @@
+package org.graph4j.alg;
+
+import org.graph4j.Graph;
+import org.graph4j.GraphBuilder;
+import org.graph4j.alg.assignment.HungarianAlgorithm;
+import org.graph4j.util.Matching;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Testing class for the Hungarian algorithm.
+ * Uses the test present on wikipedia
+ *
+ * @author Chirvasa Matei
+ * @author Prodan Sabina
+ */
+public class HungarianAlgorithmTest {
+
+ @Test
+ public void wikipediaTest() {
+ final int ALICE = 0;
+ final int BOB = 1;
+ final int DORA = 2;
+ final int CLEAN_BATHROOM = 3;
+ final int SWEEP_FLOORS = 4;
+ final int WASH_WINDOWS = 5;
+ Graph g = GraphBuilder.numVertices(6).buildGraph();
+ g.addWeightedEdge(ALICE, CLEAN_BATHROOM, 8);
+ g.addWeightedEdge(BOB, CLEAN_BATHROOM, 5);
+ g.addWeightedEdge(DORA, CLEAN_BATHROOM, 9);
+ g.addWeightedEdge(ALICE, SWEEP_FLOORS, 4);
+ g.addWeightedEdge(BOB, SWEEP_FLOORS, 2);
+ g.addWeightedEdge(DORA, SWEEP_FLOORS, 4);
+ g.addWeightedEdge(ALICE, WASH_WINDOWS, 7);
+ g.addWeightedEdge(BOB, WASH_WINDOWS, 3);
+ g.addWeightedEdge(DORA, WASH_WINDOWS, 8);
+
+ HungarianAlgorithm h = new HungarianAlgorithm(g);
+ Matching m = h.getMatching();
+ int cost = 0;
+ for (int[] edge : m.edges()) {
+ cost += (int) g.getEdgeWeight(edge[0], edge[1]);
+ }
+ assert(cost == 15);
+ }
+
+}