From 742529a92d17ee8c280409000ae2ff76f32faa2b Mon Sep 17 00:00:00 2001 From: rembo10 Date: Thu, 18 Jan 2024 16:12:15 +0530 Subject: [PATCH] munkres: 1.0.6 -> 1.1.4 --- lib/munkres.py | 542 ++++++++++++++++--------------------------------- 1 file changed, 180 insertions(+), 362 deletions(-) diff --git a/lib/munkres.py b/lib/munkres.py index ab982d7e..2f2edbc5 100644 --- a/lib/munkres.py +++ b/lib/munkres.py @@ -1,8 +1,3 @@ -#!/usr/bin/env python -# -*- coding: iso-8859-1 -*- - -# Documentation is intended to be processed by Epydoc. - """ Introduction ============ @@ -11,266 +6,10 @@ The Munkres module provides an implementation of the Munkres algorithm (also called the Hungarian algorithm or the Kuhn-Munkres algorithm), useful for solving the Assignment Problem. -Assignment Problem -================== - -Let *C* be an *n*\ x\ *n* matrix representing the costs of each of *n* workers -to perform any of *n* jobs. The assignment problem is to assign jobs to -workers in a way that minimizes the total cost. Since each worker can perform -only one job and each job can be assigned to only one worker the assignments -represent an independent set of the matrix *C*. - -One way to generate the optimal set is to create all permutations of -the indexes necessary to traverse the matrix so that no row and column -are used more than once. For instance, given this matrix (expressed in -Python):: - - matrix = [[5, 9, 1], - [10, 3, 2], - [8, 7, 4]] - -You could use this code to generate the traversal indexes:: - - def permute(a, results): - if len(a) == 1: - results.insert(len(results), a) - - else: - for i in range(0, len(a)): - element = a[i] - a_copy = [a[j] for j in range(0, len(a)) if j != i] - subresults = [] - permute(a_copy, subresults) - for subresult in subresults: - result = [element] + subresult - results.insert(len(results), result) - - results = [] - permute(range(len(matrix)), results) # [0, 1, 2] for a 3x3 matrix - -After the call to permute(), the results matrix would look like this:: - - [[0, 1, 2], - [0, 2, 1], - [1, 0, 2], - [1, 2, 0], - [2, 0, 1], - [2, 1, 0]] - -You could then use that index matrix to loop over the original cost matrix -and calculate the smallest cost of the combinations:: - - n = len(matrix) - minval = sys.maxsize - for row in range(n): - cost = 0 - for col in range(n): - cost += matrix[row][col] - minval = min(cost, minval) - - print minval - -While this approach works fine for small matrices, it does not scale. It -executes in O(*n*!) time: Calculating the permutations for an *n*\ x\ *n* -matrix requires *n*! operations. For a 12x12 matrix, that's 479,001,600 -traversals. Even if you could manage to perform each traversal in just one -millisecond, it would still take more than 133 hours to perform the entire -traversal. A 20x20 matrix would take 2,432,902,008,176,640,000 operations. At -an optimistic millisecond per operation, that's more than 77 million years. - -The Munkres algorithm runs in O(*n*\ ^3) time, rather than O(*n*!). This -package provides an implementation of that algorithm. - -This version is based on -http://www.public.iastate.edu/~ddoty/HungarianAlgorithm.html. - -This version was written for Python by Brian Clapper from the (Ada) algorithm -at the above web site. (The ``Algorithm::Munkres`` Perl version, in CPAN, was -clearly adapted from the same web site.) - -Usage -===== - -Construct a Munkres object:: - - from munkres import Munkres - - m = Munkres() - -Then use it to compute the lowest cost assignment from a cost matrix. Here's -a sample program:: - - from munkres import Munkres, print_matrix - - matrix = [[5, 9, 1], - [10, 3, 2], - [8, 7, 4]] - m = Munkres() - indexes = m.compute(matrix) - print_matrix(matrix, msg='Lowest cost through this matrix:') - total = 0 - for row, column in indexes: - value = matrix[row][column] - total += value - print '(%d, %d) -> %d' % (row, column, value) - print 'total cost: %d' % total - -Running that program produces:: - - Lowest cost through this matrix: - [5, 9, 1] - [10, 3, 2] - [8, 7, 4] - (0, 0) -> 5 - (1, 1) -> 3 - (2, 2) -> 4 - total cost=12 - -The instantiated Munkres object can be used multiple times on different -matrices. - -Non-square Cost Matrices -======================== - -The Munkres algorithm assumes that the cost matrix is square. However, it's -possible to use a rectangular matrix if you first pad it with 0 values to make -it square. This module automatically pads rectangular cost matrices to make -them square. - -Notes: - -- The module operates on a *copy* of the caller's matrix, so any padding will - not be seen by the caller. -- The cost matrix must be rectangular or square. An irregular matrix will - *not* work. - -Calculating Profit, Rather than Cost -==================================== - -The cost matrix is just that: A cost matrix. The Munkres algorithm finds -the combination of elements (one from each row and column) that results in -the smallest cost. It's also possible to use the algorithm to maximize -profit. To do that, however, you have to convert your profit matrix to a -cost matrix. The simplest way to do that is to subtract all elements from a -large value. For example:: - - from munkres import Munkres, print_matrix - - matrix = [[5, 9, 1], - [10, 3, 2], - [8, 7, 4]] - cost_matrix = [] - for row in matrix: - cost_row = [] - for col in row: - cost_row += [sys.maxsize - col] - cost_matrix += [cost_row] - - m = Munkres() - indexes = m.compute(cost_matrix) - print_matrix(matrix, msg='Highest profit through this matrix:') - total = 0 - for row, column in indexes: - value = matrix[row][column] - total += value - print '(%d, %d) -> %d' % (row, column, value) - - print 'total profit=%d' % total - -Running that program produces:: - - Highest profit through this matrix: - [5, 9, 1] - [10, 3, 2] - [8, 7, 4] - (0, 1) -> 9 - (1, 0) -> 10 - (2, 2) -> 4 - total profit=23 - -The ``munkres`` module provides a convenience method for creating a cost -matrix from a profit matrix. Since it doesn't know whether the matrix contains -floating point numbers, decimals, or integers, you have to provide the -conversion function; but the convenience method takes care of the actual -creation of the cost matrix:: - - import munkres - - cost_matrix = munkres.make_cost_matrix(matrix, - lambda cost: sys.maxsize - cost) - -So, the above profit-calculation program can be recast as:: - - from munkres import Munkres, print_matrix, make_cost_matrix - - matrix = [[5, 9, 1], - [10, 3, 2], - [8, 7, 4]] - cost_matrix = make_cost_matrix(matrix, lambda cost: sys.maxsize - cost) - m = Munkres() - indexes = m.compute(cost_matrix) - print_matrix(matrix, msg='Lowest cost through this matrix:') - total = 0 - for row, column in indexes: - value = matrix[row][column] - total += value - print '(%d, %d) -> %d' % (row, column, value) - print 'total profit=%d' % total - -References -========== - -1. http://www.public.iastate.edu/~ddoty/HungarianAlgorithm.html - -2. Harold W. Kuhn. The Hungarian Method for the assignment problem. - *Naval Research Logistics Quarterly*, 2:83-97, 1955. - -3. Harold W. Kuhn. Variants of the Hungarian method for assignment - problems. *Naval Research Logistics Quarterly*, 3: 253-258, 1956. - -4. Munkres, J. Algorithms for the Assignment and Transportation Problems. - *Journal of the Society of Industrial and Applied Mathematics*, - 5(1):32-38, March, 1957. - -5. http://en.wikipedia.org/wiki/Hungarian_algorithm - -Copyright and License -===================== - -This software is released under a BSD license, adapted from - - -Copyright (c) 2008 Brian M. Clapper -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither the name "clapper.org" nor the names of its contributors may be - used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. +For complete usage documentation, see: https://software.clapper.org/munkres/ """ -__docformat__ = 'restructuredtext' +__docformat__ = 'markdown' # --------------------------------------------------------------------------- # Imports @@ -278,23 +17,43 @@ __docformat__ = 'restructuredtext' import sys import copy +from typing import Union, NewType, Sequence, Tuple, Optional, Callable # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- -__all__ = ['Munkres', 'make_cost_matrix'] +__all__ = ['Munkres', 'make_cost_matrix', 'DISALLOWED'] # --------------------------------------------------------------------------- # Globals # --------------------------------------------------------------------------- +AnyNum = NewType('AnyNum', Union[int, float]) +Matrix = NewType('Matrix', Sequence[Sequence[AnyNum]]) + # Info about the module -__version__ = "1.0.6" +__version__ = "1.1.4" __author__ = "Brian Clapper, bmc@clapper.org" -__url__ = "http://software.clapper.org/munkres/" -__copyright__ = "(c) 2008 Brian M. Clapper" -__license__ = "BSD-style license" +__url__ = "https://software.clapper.org/munkres/" +__copyright__ = "(c) 2008-2020 Brian M. Clapper" +__license__ = "Apache Software License" + +# Constants +class DISALLOWED_OBJ(object): + pass +DISALLOWED = DISALLOWED_OBJ() +DISALLOWED_PRINTVAL = "D" + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + +class UnsolvableMatrix(Exception): + """ + Exception raised for unsolvable matrices + """ + pass # --------------------------------------------------------------------------- # Classes @@ -317,30 +76,18 @@ class Munkres: self.marked = None self.path = None - def make_cost_matrix(profit_matrix, inversion_function): - """ - **DEPRECATED** - - Please use the module function ``make_cost_matrix()``. - """ - import munkres - return munkres.make_cost_matrix(profit_matrix, inversion_function) - - make_cost_matrix = staticmethod(make_cost_matrix) - - def pad_matrix(self, matrix, pad_value=0): + def pad_matrix(self, matrix: Matrix, pad_value: int=0) -> Matrix: """ Pad a possibly non-square matrix to make it square. - :Parameters: - matrix : list of lists - matrix to pad + **Parameters** - pad_value : int - value to use to pad the matrix + - `matrix` (list of lists of numbers): matrix to pad + - `pad_value` (`int`): value to use to pad the matrix - :rtype: list of lists - :return: a new, possibly padded, matrix + **Returns** + + a new, possibly padded, matrix """ max_columns = 0 total_rows = len(matrix) @@ -356,34 +103,35 @@ class Munkres: new_row = row[:] if total_rows > row_len: # Row too short. Pad it. - new_row += [0] * (total_rows - row_len) + new_row += [pad_value] * (total_rows - row_len) new_matrix += [new_row] while len(new_matrix) < total_rows: - new_matrix += [[0] * total_rows] + new_matrix += [[pad_value] * total_rows] return new_matrix - def compute(self, cost_matrix): + def compute(self, cost_matrix: Matrix) -> Sequence[Tuple[int, int]]: """ Compute the indexes for the lowest-cost pairings between rows and - columns in the database. Returns a list of (row, column) tuples + columns in the database. Returns a list of `(row, column)` tuples that can be used to traverse the matrix. - :Parameters: - cost_matrix : list of lists - The cost matrix. If this cost matrix is not square, it - will be padded with zeros, via a call to ``pad_matrix()``. - (This method does *not* modify the caller's matrix. It - operates on a copy of the matrix.) + **WARNING**: This code handles square and rectangular matrices. It + does *not* handle irregular matrices. - **WARNING**: This code handles square and rectangular - matrices. It does *not* handle irregular matrices. + **Parameters** - :rtype: list - :return: A list of ``(row, column)`` tuples that describe the lowest - cost path through the matrix + - `cost_matrix` (list of lists of numbers): The cost matrix. If this + cost matrix is not square, it will be padded with zeros, via a call + to `pad_matrix()`. (This method does *not* modify the caller's + matrix. It operates on a copy of the matrix.) + + **Returns** + + A list of `(row, column)` tuples that describe the lowest cost path + through the matrix """ self.C = self.pad_matrix(cost_matrix) self.n = len(self.C) @@ -422,18 +170,18 @@ class Munkres: return results - def __copy_matrix(self, matrix): + def __copy_matrix(self, matrix: Matrix) -> Matrix: """Return an exact copy of the supplied matrix""" return copy.deepcopy(matrix) - def __make_matrix(self, n, val): + def __make_matrix(self, n: int, val: AnyNum) -> Matrix: """Create an *n*x*n* matrix, populating it with the specific value.""" matrix = [] for i in range(n): matrix += [[val for j in range(n)]] return matrix - def __step1(self): + def __step1(self) -> int: """ For each row of the matrix, find the smallest element and subtract it from every element in its row. Go to Step 2. @@ -441,15 +189,22 @@ class Munkres: C = self.C n = self.n for i in range(n): - minval = min(self.C[i]) + vals = [x for x in self.C[i] if x is not DISALLOWED] + if len(vals) == 0: + # All values in this row are DISALLOWED. This matrix is + # unsolvable. + raise UnsolvableMatrix( + "Row {0} is entirely DISALLOWED.".format(i) + ) + minval = min(vals) # Find the minimum value for this row and subtract that minimum # from every element in the row. for j in range(n): - self.C[i][j] -= minval - + if self.C[i][j] is not DISALLOWED: + self.C[i][j] -= minval return 2 - def __step2(self): + def __step2(self) -> int: """ Find a zero (Z) in the resulting matrix. If there is no starred zero in its row or column, star Z. Repeat for each element in the @@ -464,11 +219,12 @@ class Munkres: self.marked[i][j] = 1 self.col_covered[j] = True self.row_covered[i] = True + break self.__clear_covers() return 3 - def __step3(self): + def __step3(self) -> int: """ Cover each column containing a starred zero. If K columns are covered, the starred zeros describe a complete set of unique @@ -478,7 +234,7 @@ class Munkres: count = 0 for i in range(n): for j in range(n): - if self.marked[i][j] == 1: + if self.marked[i][j] == 1 and not self.col_covered[j]: self.col_covered[j] = True count += 1 @@ -489,7 +245,7 @@ class Munkres: return step - def __step4(self): + def __step4(self) -> int: """ Find a noncovered zero and prime it. If there is no starred zero in the row containing this primed zero, Go to Step 5. Otherwise, @@ -499,11 +255,11 @@ class Munkres: """ step = 0 done = False - row = -1 - col = -1 + row = 0 + col = 0 star_col = -1 while not done: - (row, col) = self.__find_a_zero() + (row, col) = self.__find_a_zero(row, col) if row < 0: done = True step = 6 @@ -522,7 +278,7 @@ class Munkres: return step - def __step5(self): + def __step5(self) -> int: """ Construct a series of alternating primed and starred zeros as follows. Let Z0 represent the uncovered primed zero found in Step 4. @@ -558,7 +314,7 @@ class Munkres: self.__erase_primes() return 3 - def __step6(self): + def __step6(self) -> int: """ Add the value found in Step 4 to every element of each covered row, and subtract it from every element of each uncovered column. @@ -566,34 +322,44 @@ class Munkres: lines. """ minval = self.__find_smallest() + events = 0 # track actual changes to matrix for i in range(self.n): for j in range(self.n): + if self.C[i][j] is DISALLOWED: + continue if self.row_covered[i]: self.C[i][j] += minval + events += 1 if not self.col_covered[j]: self.C[i][j] -= minval + events += 1 + if self.row_covered[i] and not self.col_covered[j]: + events -= 2 # change reversed, no real difference + if (events == 0): + raise UnsolvableMatrix("Matrix cannot be solved!") return 4 - def __find_smallest(self): + def __find_smallest(self) -> AnyNum: """Find the smallest uncovered value in the matrix.""" minval = sys.maxsize for i in range(self.n): for j in range(self.n): if (not self.row_covered[i]) and (not self.col_covered[j]): - if minval > self.C[i][j]: + if self.C[i][j] is not DISALLOWED and minval > self.C[i][j]: minval = self.C[i][j] return minval - def __find_a_zero(self): + + def __find_a_zero(self, i0: int = 0, j0: int = 0) -> Tuple[int, int]: """Find the first uncovered element with value 0""" row = -1 col = -1 - i = 0 + i = i0 n = self.n done = False while not done: - j = 0 + j = j0 while True: if (self.C[i][j] == 0) and \ (not self.row_covered[i]) and \ @@ -601,16 +367,16 @@ class Munkres: row = i col = j done = True - j += 1 - if j >= n: + j = (j + 1) % n + if j == j0: break - i += 1 - if i >= n: + i = (i + 1) % n + if i == i0: done = True return (row, col) - def __find_star_in_row(self, row): + def __find_star_in_row(self, row: Sequence[AnyNum]) -> int: """ Find the first starred element in the specified row. Returns the column index, or -1 if no starred element was found. @@ -623,7 +389,7 @@ class Munkres: return col - def __find_star_in_col(self, col): + def __find_star_in_col(self, col: Sequence[AnyNum]) -> int: """ Find the first starred element in the specified row. Returns the row index, or -1 if no starred element was found. @@ -636,7 +402,7 @@ class Munkres: return row - def __find_prime_in_row(self, row): + def __find_prime_in_row(self, row) -> int: """ Find the first prime element in the specified row. Returns the column index, or -1 if no starred element was found. @@ -649,20 +415,22 @@ class Munkres: return col - def __convert_path(self, path, count): + def __convert_path(self, + path: Sequence[Sequence[int]], + count: int) -> None: for i in range(count+1): if self.marked[path[i][0]][path[i][1]] == 1: self.marked[path[i][0]][path[i][1]] = 0 else: self.marked[path[i][0]][path[i][1]] = 1 - def __clear_covers(self): + def __clear_covers(self) -> None: """Clear all covered matrix cells""" for i in range(self.n): self.row_covered[i] = False self.col_covered[i] = False - def __erase_primes(self): + def __erase_primes(self) -> None: """Erase all prime markings""" for i in range(self.n): for j in range(self.n): @@ -673,51 +441,56 @@ class Munkres: # Functions # --------------------------------------------------------------------------- -def make_cost_matrix(profit_matrix, inversion_function): +def make_cost_matrix( + profit_matrix: Matrix, + inversion_function: Optional[Callable[[AnyNum], AnyNum]] = None + ) -> Matrix: """ - Create a cost matrix from a profit matrix by calling - 'inversion_function' to invert each value. The inversion - function must take one numeric argument (of any type) and return - another numeric argument which is presumed to be the cost inverse - of the original profit. + Create a cost matrix from a profit matrix by calling `inversion_function()` + to invert each value. The inversion function must take one numeric argument + (of any type) and return another numeric argument which is presumed to be + the cost inverse of the original profit value. If the inversion function + is not provided, a given cell's inverted value is calculated as + `max(matrix) - value`. This is a static method. Call it like this: - .. python:: - + from munkres import Munkres cost_matrix = Munkres.make_cost_matrix(matrix, inversion_func) For example: - .. python:: - + from munkres import Munkres cost_matrix = Munkres.make_cost_matrix(matrix, lambda x : sys.maxsize - x) - :Parameters: - profit_matrix : list of lists - The matrix to convert from a profit to a cost matrix + **Parameters** - inversion_function : function - The function to use to invert each entry in the profit matrix + - `profit_matrix` (list of lists of numbers): The matrix to convert from + profit to cost values. + - `inversion_function` (`function`): The function to use to invert each + entry in the profit matrix. - :rtype: list of lists - :return: The converted matrix + **Returns** + + A new matrix representing the inversion of `profix_matrix`. """ + if not inversion_function: + maximum = max(max(row) for row in profit_matrix) + inversion_function = lambda x: maximum - x + cost_matrix = [] for row in profit_matrix: cost_matrix.append([inversion_function(value) for value in row]) return cost_matrix -def print_matrix(matrix, msg=None): +def print_matrix(matrix: Matrix, msg: Optional[str] = None) -> None: """ - Convenience function: Displays the contents of a matrix of integers. + Convenience function: Displays the contents of a matrix. - :Parameters: - matrix : list of lists - Matrix to print + **Parameters** - msg : str - Optional message to print before displaying the matrix + - `matrix` (list of lists of numbers): The matrix to print + - `msg` (`str`): Optional message to print before displaying the matrix """ import math @@ -728,16 +501,21 @@ def print_matrix(matrix, msg=None): width = 0 for row in matrix: for val in row: - width = max(width, int(math.log10(val)) + 1) + if val is DISALLOWED: + val = DISALLOWED_PRINTVAL + width = max(width, len(str(val))) # Make the format string - format = '%%%dd' % width + format = ('%%%d' % width) # Print the matrix for row in matrix: sep = '[' for val in row: - sys.stdout.write(sep + format % val) + if val is DISALLOWED: + val = DISALLOWED_PRINTVAL + formatted = ((format + 's') % val) + sys.stdout.write(sep + formatted) sep = ', ' sys.stdout.write(']\n') @@ -767,11 +545,51 @@ if __name__ == '__main__': [9, 7, 4]], 18), + # Square variant with floating point value + ([[10.1, 10.2, 8.3], + [9.4, 8.5, 1.6], + [9.7, 7.8, 4.9]], + 19.5), + # Rectangular variant ([[10, 10, 8, 11], [9, 8, 1, 1], [9, 7, 4, 10]], - 15)] + 15), + + # Rectangular variant with floating point value + ([[10.01, 10.02, 8.03, 11.04], + [9.05, 8.06, 1.07, 1.08], + [9.09, 7.1, 4.11, 10.12]], + 15.2), + + # Rectangular with DISALLOWED + ([[4, 5, 6, DISALLOWED], + [1, 9, 12, 11], + [DISALLOWED, 5, 4, DISALLOWED], + [12, 12, 12, 10]], + 20), + + # Rectangular variant with DISALLOWED and floating point value + ([[4.001, 5.002, 6.003, DISALLOWED], + [1.004, 9.005, 12.006, 11.007], + [DISALLOWED, 5.008, 4.009, DISALLOWED], + [12.01, 12.011, 12.012, 10.013]], + 20.028), + + # DISALLOWED to force pairings + ([[1, DISALLOWED, DISALLOWED, DISALLOWED], + [DISALLOWED, 2, DISALLOWED, DISALLOWED], + [DISALLOWED, DISALLOWED, 3, DISALLOWED], + [DISALLOWED, DISALLOWED, DISALLOWED, 4]], + 10), + + # DISALLOWED to force pairings with floating point value + ([[1.1, DISALLOWED, DISALLOWED, DISALLOWED], + [DISALLOWED, 2.2, DISALLOWED, DISALLOWED], + [DISALLOWED, DISALLOWED, 3.3, DISALLOWED], + [DISALLOWED, DISALLOWED, DISALLOWED, 4.4]], + 11.0)] m = Munkres() for cost_matrix, expected_total in matrices: @@ -781,6 +599,6 @@ if __name__ == '__main__': for r, c in indexes: x = cost_matrix[r][c] total_cost += x - print(('(%d, %d) -> %d' % (r, c, x))) - print(('lowest cost=%d' % total_cost)) + print(('(%d, %d) -> %s' % (r, c, x))) + print(('lowest cost=%s' % total_cost)) assert expected_total == total_cost